readloops 2.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,345 @@
1
+ """智能词汇测试服务。
2
+ 基于用户阅读数据的权重抽样,优先出不会的词。
3
+ 支持多题型:单词释义、中文选单词、词形变化、短语搭配。
4
+ """
5
+ import json
6
+ import random
7
+ import time
8
+
9
+ from app.database import get_db
10
+
11
+
12
+ def calculate_word_weight(word):
13
+ """计算一个词的出题权重。权重越高,越容易被选中。"""
14
+ weight = 1.0
15
+
16
+ # 生词本:高权重
17
+ if word['status'] == 'learning':
18
+ weight *= 3.0
19
+
20
+ # 测验词(查词≥2次):高权重
21
+ if word['status'] == 'target':
22
+ weight *= 3.0
23
+
24
+ # 查词次数:查得越多越重要
25
+ lookup_count = word['lookup_count'] or 0
26
+ weight *= (1 + lookup_count * 0.5)
27
+
28
+ # 测试错误次数:错得越多越要练
29
+ wrong_count = word['wrong_count'] or 0
30
+ weight *= (1 + wrong_count * 1.0)
31
+
32
+ # 测试正确次数:对得多降低权重
33
+ correct_count = word['correct_count'] or 0
34
+ if correct_count > 0:
35
+ weight *= max(0.3, 1.0 - correct_count * 0.1)
36
+
37
+ # FSRS 记忆强度:越低越要复习
38
+ stability = word['srs_stability'] or 0
39
+ if stability > 0 and stability < 10:
40
+ weight *= 2.0
41
+ elif stability >= 10 and stability < 30:
42
+ weight *= 1.5
43
+
44
+ # 最近测试过的词降低权重(避免连续重复)
45
+ last_test_at = word['last_test_at'] or 0
46
+ if last_test_at > 0:
47
+ days_since = (time.time() - last_test_at) / 86400
48
+ if days_since < 1:
49
+ weight *= 0.5
50
+
51
+ return weight
52
+
53
+
54
+ def weighted_sample(words, count):
55
+ """按权重抽样。"""
56
+ if not words:
57
+ return []
58
+
59
+ weights = [calculate_word_weight(w) for w in words]
60
+ total = sum(weights)
61
+
62
+ if total == 0:
63
+ return random.sample(words, min(count, len(words)))
64
+
65
+ result = []
66
+ available = list(zip(words, weights))
67
+
68
+ while len(result) < count and available:
69
+ r = random.uniform(0, total)
70
+ cumulative = 0
71
+ for i, (word, weight) in enumerate(available):
72
+ cumulative += weight
73
+ if cumulative >= r:
74
+ result.append(word)
75
+ total -= weight
76
+ available.pop(i)
77
+ break
78
+ else:
79
+ # 浮点误差,选最后一个
80
+ if available:
81
+ result.append(available[-1][0])
82
+ total -= available[-1][1]
83
+ available.pop()
84
+
85
+ return result
86
+
87
+
88
+ def generate_smart_test(count=20, source='adaptive'):
89
+ """生成智能测试。
90
+ source:
91
+ - adaptive: 自适应(基于用户数据权重抽样,默认)
92
+ - vocab: 仅生词本
93
+ - target: 仅测验词
94
+ - all: 全部词库(随机)
95
+ """
96
+ with get_db() as conn:
97
+ # 获取候选词
98
+ if source == 'adaptive':
99
+ # 自适应:生词本 + 测验词 + 查词≥1的词 + CET4高频词补充
100
+ candidates = conn.execute("""
101
+ SELECT id, text, meaning, exchange, status, lookup_count,
102
+ wrong_count, correct_count, srs_stability, last_test_at, level
103
+ FROM words
104
+ WHERE meaning IS NOT NULL AND meaning != ''
105
+ AND (status IN ('learning', 'target')
106
+ OR lookup_count >= 1
107
+ OR level = 'CET4')
108
+ ORDER BY lookup_count DESC, wrong_count DESC
109
+ LIMIT 500
110
+ """).fetchall()
111
+ elif source == 'vocab':
112
+ candidates = conn.execute("""
113
+ SELECT id, text, meaning, exchange, status, lookup_count,
114
+ wrong_count, correct_count, srs_stability, last_test_at, level
115
+ FROM words
116
+ WHERE status = 'learning' AND meaning IS NOT NULL AND meaning != ''
117
+ LIMIT 500
118
+ """).fetchall()
119
+ elif source == 'target':
120
+ candidates = conn.execute("""
121
+ SELECT id, text, meaning, exchange, status, lookup_count,
122
+ wrong_count, correct_count, srs_stability, last_test_at, level
123
+ FROM words
124
+ WHERE status = 'target' AND meaning IS NOT NULL AND meaning != ''
125
+ LIMIT 500
126
+ """).fetchall()
127
+ else:
128
+ candidates = conn.execute("""
129
+ SELECT id, text, meaning, exchange, status, lookup_count,
130
+ wrong_count, correct_count, srs_stability, last_test_at, level
131
+ FROM words
132
+ WHERE meaning IS NOT NULL AND meaning != ''
133
+ ORDER BY RANDOM() LIMIT 500
134
+ """).fetchall()
135
+
136
+ # 干扰项词库
137
+ all_words = conn.execute("""
138
+ SELECT text, meaning FROM words
139
+ WHERE meaning IS NOT NULL AND meaning != ''
140
+ ORDER BY RANDOM() LIMIT 200
141
+ """).fetchall()
142
+
143
+ # 短语库
144
+ phrases = conn.execute("""
145
+ SELECT text, frequency FROM phrases
146
+ WHERE frequency >= 3 ORDER BY frequency DESC LIMIT 100
147
+ """).fetchall()
148
+
149
+ # 按权重抽样
150
+ selected = weighted_sample(candidates, count)
151
+
152
+ # 生成题目
153
+ result = []
154
+ for word in selected:
155
+ question_type = choose_question_type(word)
156
+ question = generate_question(word, question_type, all_words, phrases)
157
+ if question:
158
+ result.append(question)
159
+
160
+ return result
161
+
162
+
163
+ def choose_question_type(word):
164
+ """选择题型。"""
165
+ has_exchange = word['exchange'] and word['exchange'] != 'null'
166
+
167
+ # 70% 单词释义,15% 中文选单词,10% 词形变化,5% 短语
168
+ r = random.random()
169
+ if r < 0.7:
170
+ return 'word_to_meaning'
171
+ elif r < 0.85:
172
+ return 'meaning_to_word'
173
+ elif r < 0.95 and has_exchange:
174
+ return 'word_form'
175
+ else:
176
+ return 'word_to_meaning'
177
+
178
+
179
+ def generate_question(word, question_type, all_words, phrases):
180
+ """生成一道题。"""
181
+ meaning = word['meaning']
182
+ text = word['text']
183
+
184
+ if question_type == 'word_to_meaning':
185
+ # 英文单词选中文释义
186
+ distractors = random.sample(
187
+ [w for w in all_words if w['text'] != text],
188
+ min(3, len(all_words) - 1)
189
+ )
190
+ options = [meaning] + [d['meaning'] for d in distractors]
191
+ random.shuffle(options)
192
+ return {
193
+ 'word_id': word['id'],
194
+ 'word': text,
195
+ 'type': 'word_to_meaning',
196
+ 'question': f"选择 \"{text}\" 的正确释义",
197
+ 'options': options,
198
+ 'answer': meaning,
199
+ }
200
+
201
+ elif question_type == 'meaning_to_word':
202
+ # 中文释义选英文单词
203
+ distractors = random.sample(
204
+ [w for w in all_words if w['text'] != text],
205
+ min(3, len(all_words) - 1)
206
+ )
207
+ options = [text] + [d['text'] for d in distractors]
208
+ random.shuffle(options)
209
+ return {
210
+ 'word_id': word['id'],
211
+ 'word': text,
212
+ 'type': 'meaning_to_word',
213
+ 'question': f"选择释义为 \"{meaning[:30]}\" 的单词",
214
+ 'options': options,
215
+ 'answer': text,
216
+ }
217
+
218
+ elif question_type == 'word_form':
219
+ # 词形变化题
220
+ try:
221
+ exchange = json.loads(word['exchange']) if isinstance(word['exchange'], str) else word['exchange']
222
+ except Exception:
223
+ exchange = {}
224
+
225
+ if not exchange:
226
+ return None
227
+
228
+ # 选一个词形变化类型
229
+ form_types = [k for k in exchange.keys() if k != 'lemma']
230
+ if not form_types:
231
+ return None
232
+
233
+ form_type = random.choice(form_types)
234
+ form_word = exchange[form_type]
235
+
236
+ type_names = {
237
+ 'plural': '复数形式',
238
+ 'ing': '现在分词',
239
+ 'past': '过去式',
240
+ 'done': '过去分词',
241
+ 'er': '比较级',
242
+ 'est': '最高级',
243
+ 'third': '第三人称单数',
244
+ }
245
+ type_name = type_names.get(form_type, form_type)
246
+
247
+ distractors = random.sample(
248
+ [w for w in all_words if w['text'] != form_word],
249
+ min(3, len(all_words) - 1)
250
+ )
251
+ options = [form_word] + [d['text'] for d in distractors]
252
+ random.shuffle(options)
253
+
254
+ return {
255
+ 'word_id': word['id'],
256
+ 'word': text,
257
+ 'type': 'word_form',
258
+ 'question': f"\"{text}\" 的{type_name}是",
259
+ 'options': options,
260
+ 'answer': form_word,
261
+ }
262
+
263
+ return None
264
+
265
+
266
+ def submit_test_result(test_id, word_id, is_correct, reaction_time=None):
267
+ """提交测试结果,更新词的统计数据和 FSRS。"""
268
+ from app.services.srs import update_after_review
269
+ with get_db() as conn:
270
+ now = int(time.time())
271
+
272
+ # 更新词的统计
273
+ if is_correct:
274
+ conn.execute("""
275
+ UPDATE words SET correct_count = COALESCE(correct_count, 0) + 1,
276
+ last_test_at = ?
277
+ WHERE id = ?
278
+ """, (now, word_id))
279
+ # FSRS: 正确 → 良好/简单(根据反应时间)
280
+ if reaction_time and reaction_time < 3000:
281
+ rating = 4 # 简单(反应快)
282
+ else:
283
+ rating = 3 # 良好
284
+ else:
285
+ conn.execute("""
286
+ UPDATE words SET wrong_count = COALESCE(wrong_count, 0) + 1,
287
+ last_test_at = ?
288
+ WHERE id = ?
289
+ """, (now, word_id))
290
+
291
+ # 错误的词自动加入生词本(如果还没在)
292
+ word = conn.execute("SELECT status FROM words WHERE id = ?", (word_id,)).fetchone()
293
+ if word and word['status'] not in ('learning', 'known'):
294
+ conn.execute("UPDATE words SET status = 'learning' WHERE id = ?", (word_id,))
295
+ # FSRS: 错误 → 忘记/困难
296
+ if reaction_time and reaction_time > 10000:
297
+ rating = 1 # 忘记(反应慢还错)
298
+ else:
299
+ rating = 2 # 困难
300
+
301
+ # 更新 FSRS
302
+ try:
303
+ update_after_review(word_id, rating, conn)
304
+ except Exception as e:
305
+ # FSRS 更新失败不影响主流程
306
+ print(f"FSRS update failed: {e}")
307
+
308
+ # 记录题目详情
309
+ conn.execute("""
310
+ INSERT INTO test_questions (test_id, word_id, is_correct, reaction_time, created_at)
311
+ VALUES (?, ?, ?, ?, ?)
312
+ """, (test_id, word_id, 1 if is_correct else 0, reaction_time, now))
313
+
314
+ conn.commit()
315
+
316
+
317
+ def get_test_report(test_id):
318
+ """获取测试报告。"""
319
+ with get_db() as conn:
320
+ questions = conn.execute("""
321
+ SELECT q.*, w.text as word_text, w.meaning as word_meaning
322
+ FROM test_questions q
323
+ LEFT JOIN words w ON q.word_id = w.id
324
+ WHERE q.test_id = ?
325
+ ORDER BY q.id
326
+ """, (test_id,)).fetchall()
327
+
328
+ total = len(questions)
329
+ correct = sum(1 for q in questions if q['is_correct'])
330
+ wrong = total - correct
331
+ accuracy = (correct / total * 100) if total > 0 else 0
332
+
333
+ wrong_words = [
334
+ {'word': q['word_text'], 'meaning': q['word_meaning']}
335
+ for q in questions if not q['is_correct']
336
+ ]
337
+
338
+ return {
339
+ 'test_id': test_id,
340
+ 'total': total,
341
+ 'correct': correct,
342
+ 'wrong': wrong,
343
+ 'accuracy': round(accuracy, 1),
344
+ 'wrong_words': wrong_words,
345
+ }
app/services/srs.py ADDED
@@ -0,0 +1,154 @@
1
+ """FSRS 间隔重复调度服务。
2
+
3
+ 简化版 FSRS:基于记忆稳定性(stability)和难度(difficulty)计算下次复习时间。
4
+ 参考:https://github.com/open-spaced-repetition/fsrs4anki
5
+ """
6
+ import time
7
+
8
+
9
+ def _now():
10
+ return int(time.time())
11
+
12
+
13
+ def init_srs(word_id: int, conn):
14
+ """初始化一个词的 FSRS 参数。"""
15
+ now = _now()
16
+ conn.execute(
17
+ "UPDATE words SET srs_stability=1.0, srs_difficulty=5.0, srs_interval=0, "
18
+ "srs_reps=0, srs_lapses=0, srs_due=?, srs_last_review=? WHERE id=?",
19
+ (now, now, word_id),
20
+ )
21
+
22
+
23
+ def update_after_review(word_id: int, rating: int, conn):
24
+ """复习后更新 FSRS。
25
+
26
+ rating: 1=忘记, 2=困难, 3=良好, 4=简单
27
+ """
28
+ row = conn.execute(
29
+ "SELECT srs_stability, srs_difficulty, srs_interval, srs_reps, srs_lapses "
30
+ "FROM words WHERE id=?",
31
+ (word_id,),
32
+ ).fetchone()
33
+ if not row or row["srs_stability"] is None:
34
+ init_srs(word_id, conn)
35
+ row = conn.execute(
36
+ "SELECT srs_stability, srs_difficulty, srs_interval, srs_reps, srs_lapses "
37
+ "FROM words WHERE id=?",
38
+ (word_id,),
39
+ ).fetchone()
40
+
41
+ stability = row["srs_stability"] or 1.0
42
+ difficulty = row["srs_difficulty"] or 5.0
43
+ reps = row["srs_reps"] or 0
44
+ lapses = row["srs_lapses"] or 0
45
+
46
+ # FSRS 简化算法
47
+ if rating == 1: # 忘记
48
+ lapses += 1
49
+ stability = max(0.5, stability * 0.4)
50
+ interval = 1 # 1天后复习
51
+ difficulty = min(10, difficulty + 0.5)
52
+ elif rating == 2: # 困难
53
+ stability = stability * 0.8
54
+ interval = max(1, stability * 0.8)
55
+ difficulty = min(10, difficulty + 0.2)
56
+ elif rating == 3: # 良好
57
+ stability = stability * 1.2
58
+ interval = stability
59
+ difficulty = max(1, difficulty - 0.1)
60
+ else: # 简单
61
+ stability = stability * 1.5
62
+ interval = stability * 1.3
63
+ difficulty = max(1, difficulty - 0.3)
64
+
65
+ reps += 1
66
+ now = _now()
67
+ due = now + int(interval * 86400) # 转换为秒
68
+
69
+ conn.execute(
70
+ "UPDATE words SET srs_stability=?, srs_difficulty=?, srs_interval=?, "
71
+ "srs_reps=?, srs_lapses=?, srs_due=?, srs_last_review=? WHERE id=?",
72
+ (stability, difficulty, interval, reps, lapses, due, now, word_id),
73
+ )
74
+
75
+ return {
76
+ "stability": stability,
77
+ "difficulty": difficulty,
78
+ "interval": interval,
79
+ "next_review": due,
80
+ }
81
+
82
+
83
+ def update_after_reading(word_id: int, looked_up: bool, reading_time_ratio: float, conn):
84
+ """阅读后根据行为更新 FSRS。
85
+
86
+ looked_up: 阅读中是否查了这个词
87
+ reading_time_ratio: 实际阅读时间 / 预期阅读时间(>1 读得慢,<1 读得快)
88
+ """
89
+ row = conn.execute(
90
+ "SELECT srs_stability, srs_difficulty, srs_interval, srs_reps "
91
+ "FROM words WHERE id=?",
92
+ (word_id,),
93
+ ).fetchone()
94
+ if not row or row["srs_stability"] is None:
95
+ init_srs(word_id, conn)
96
+ return
97
+
98
+ stability = row["srs_stability"] or 1.0
99
+ difficulty = row["srs_difficulty"] or 5.0
100
+
101
+ if looked_up:
102
+ # 查词了 → 难度上调,稳定性不变
103
+ difficulty = min(10, difficulty + 0.1)
104
+ else:
105
+ # 没查词 → 稳定性微增
106
+ if reading_time_ratio > 0.5 and reading_time_ratio < 2.0:
107
+ # 阅读速度正常 → 记忆有效
108
+ stability = stability * 1.05
109
+
110
+ # 读得太慢 → 难度上调
111
+ if reading_time_ratio > 2.0:
112
+ difficulty = min(10, difficulty + 0.05)
113
+
114
+ now = _now()
115
+ conn.execute(
116
+ "UPDATE words SET srs_stability=?, srs_difficulty=?, srs_last_review=? WHERE id=?",
117
+ (stability, difficulty, now, word_id),
118
+ )
119
+
120
+
121
+ def get_due_words(limit=50, conn=None):
122
+ """获取今天到期复习的词。"""
123
+ now = _now()
124
+ rows = conn.execute(
125
+ "SELECT id, text, phonetic, meaning, srs_stability, srs_interval, srs_due "
126
+ "FROM words WHERE status IN ('learning', 'target') "
127
+ "AND srs_due <= ? AND srs_stability IS NOT NULL "
128
+ "ORDER BY srs_due ASC LIMIT ?",
129
+ (now, limit),
130
+ ).fetchall()
131
+ return [dict(r) for r in rows]
132
+
133
+
134
+ def get_review_stats(conn):
135
+ """获取 FSRS 统计概览。"""
136
+ now = _now()
137
+ total = conn.execute(
138
+ "SELECT COUNT(*) as c FROM words WHERE status IN ('learning', 'target') "
139
+ "AND srs_stability IS NOT NULL"
140
+ ).fetchone()["c"]
141
+ due = conn.execute(
142
+ "SELECT COUNT(*) as c FROM words WHERE status IN ('learning', 'target') "
143
+ "AND srs_due <= ?",
144
+ (now,),
145
+ ).fetchone()["c"]
146
+ avg_stability = conn.execute(
147
+ "SELECT AVG(srs_stability) as a FROM words WHERE status IN ('learning', 'target') "
148
+ "AND srs_stability IS NOT NULL"
149
+ ).fetchone()["a"]
150
+ return {
151
+ "total": total,
152
+ "due_today": due,
153
+ "avg_stability": round(avg_stability or 0, 1),
154
+ }