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.
app/api/words.py ADDED
@@ -0,0 +1,210 @@
1
+ """单词路由:查词、生词本、批量添加。"""
2
+ import time
3
+
4
+ from fastapi import APIRouter
5
+ from pydantic import BaseModel
6
+
7
+ from app.database import get_db
8
+ from app.services.ai import lookup_word, translate_sentence
9
+
10
+ router = APIRouter(prefix="/api/words", tags=["words"])
11
+
12
+
13
+ class AddWordRequest(BaseModel):
14
+ word: str
15
+ meaning: str = ""
16
+ context: str = ""
17
+
18
+
19
+ class BatchAddRequest(BaseModel):
20
+ words: list[str]
21
+
22
+
23
+ @router.get("/lookup/{word}")
24
+ async def lookup(word: str):
25
+ """查询单词释义。优先本地,没有则问 AI。"""
26
+ result = lookup_word(word)
27
+ return {
28
+ "word": result.text,
29
+ "phonetic": result.phonetic or "",
30
+ "meaning": result.meaning or "",
31
+ "examples": [],
32
+ "in_vocab": result.id is not None,
33
+ "word_id": result.id,
34
+ }
35
+
36
+
37
+ class TranslateRequest(BaseModel):
38
+ text: str
39
+
40
+
41
+ @router.post("/translate")
42
+ async def translate(req: TranslateRequest):
43
+ """翻译句子 / 短语。
44
+
45
+ 划词选中多个单词时走这里 —— 词典查不到整句,
46
+ 以前会直接显示「查询失败」。
47
+ """
48
+ translation = translate_sentence(req.text)
49
+ return {"text": req.text, "translation": translation, "ok": bool(translation)}
50
+
51
+
52
+ @router.post("/add")
53
+ async def add_word(req: AddWordRequest):
54
+ """添加单词到生词本。"""
55
+ now = int(time.time())
56
+ with get_db() as conn:
57
+ row = conn.execute("SELECT id FROM words WHERE text = ?", (req.word,)).fetchone()
58
+ if row:
59
+ word_id = row["id"]
60
+ conn.execute(
61
+ "UPDATE words SET meaning=COALESCE(NULLIF(?, ''), meaning), status='learning', updated_at=? WHERE id=?",
62
+ (req.meaning, now, word_id),
63
+ )
64
+ else:
65
+ cur = conn.execute(
66
+ "INSERT INTO words (lemma, text, type, meaning, level, status, created_at, updated_at) "
67
+ "VALUES (?, ?, 'word', ?, 'CET4', 'learning', ?, ?)",
68
+ (req.word, req.word, req.meaning, now, now),
69
+ )
70
+ word_id = cur.lastrowid
71
+ return {"word_id": word_id, "word": req.word, "status": "added"}
72
+
73
+
74
+ @router.post("/batch-add")
75
+ async def batch_add(req: BatchAddRequest):
76
+ """批量添加单词。"""
77
+ now = int(time.time())
78
+ added = 0
79
+ with get_db() as conn:
80
+ for w in req.words:
81
+ w = w.strip().lower()
82
+ if not w or not w.isalpha():
83
+ continue
84
+ existing = conn.execute("SELECT id FROM words WHERE text = ?", (w,)).fetchone()
85
+ if not existing:
86
+ conn.execute(
87
+ "INSERT INTO words (lemma, text, type, level, status, created_at, updated_at) "
88
+ "VALUES (?, ?, 'word', 'CET4', 'learning', ?, ?)",
89
+ (w, w, now, now),
90
+ )
91
+ added += 1
92
+ return {"added": added, "total": len(req.words)}
93
+
94
+
95
+ @router.get("/vocab")
96
+ async def get_vocab(status: str = "learning", limit: int = 100):
97
+ """获取生词本列表。"""
98
+ with get_db() as conn:
99
+ rows = conn.execute(
100
+ "SELECT id, text, phonetic, meaning, level, status, encounter_count, lookup_count, created_at "
101
+ "FROM words WHERE status = ? ORDER BY created_at DESC LIMIT ?",
102
+ (status, limit),
103
+ ).fetchall()
104
+ return [
105
+ {
106
+ "id": r["id"],
107
+ "text": r["text"],
108
+ "phonetic": r["phonetic"] or "",
109
+ "meaning": r["meaning"] or "",
110
+ "level": r["level"],
111
+ "status": r["status"],
112
+ "encounter_count": r["encounter_count"],
113
+ "lookup_count": r["lookup_count"],
114
+ "created_at": r["created_at"],
115
+ }
116
+ for r in rows
117
+ ]
118
+
119
+
120
+ class BatchDeleteRequest(BaseModel):
121
+ word_ids: list[int]
122
+
123
+
124
+ class BatchStatusRequest(BaseModel):
125
+ word_ids: list[int]
126
+ status: str
127
+
128
+
129
+ @router.delete("/{word_id}")
130
+ async def delete_word(word_id: int):
131
+ """删除单个单词(同时删除相关的遇见记录)。"""
132
+ with get_db() as conn:
133
+ conn.execute("DELETE FROM word_encounters WHERE word_id=?", (word_id,))
134
+ conn.execute("DELETE FROM words WHERE id=?", (word_id,))
135
+ return {"status": "ok"}
136
+
137
+
138
+ @router.post("/batch-delete")
139
+ async def batch_delete(req: BatchDeleteRequest):
140
+ """批量删除单词。"""
141
+ with get_db() as conn:
142
+ for wid in req.word_ids:
143
+ conn.execute("DELETE FROM word_encounters WHERE word_id=?", (wid,))
144
+ conn.execute("DELETE FROM words WHERE id=?", (wid,))
145
+ return {"deleted": len(req.word_ids)}
146
+
147
+
148
+ @router.post("/batch-status")
149
+ async def batch_update_status(req: BatchStatusRequest):
150
+ """批量修改单词状态(learning/known/new/target)。"""
151
+ now = int(time.time())
152
+ with get_db() as conn:
153
+ for wid in req.word_ids:
154
+ conn.execute("UPDATE words SET status=?, updated_at=? WHERE id=?", (req.status, now, wid))
155
+ return {"updated": len(req.word_ids), "status": req.status}
156
+
157
+
158
+ @router.get("/test/generate")
159
+ async def generate_test(count: int = 20, difficulty: str = "mixed", source: str = "adaptive"):
160
+ """生成单词测试题。
161
+ source: adaptive(自适应/基于用户数据)/vocab(生词本)/target(测验词)/all(全部随机)
162
+ """
163
+ from app.services.smart_test import generate_smart_test
164
+ return generate_smart_test(count, source)
165
+
166
+
167
+ @router.post("/test/{test_id}/submit")
168
+ async def submit_test(test_id: int, word_id: int, is_correct: bool, reaction_time: int = None):
169
+ """提交单题测试结果。"""
170
+ from app.services.smart_test import submit_test_result
171
+ submit_test_result(test_id, word_id, is_correct, reaction_time)
172
+ return {"status": "ok"}
173
+
174
+
175
+ @router.get("/test/{test_id}/report")
176
+ async def get_test_report(test_id: int):
177
+ """获取测试报告。"""
178
+ from app.services.smart_test import get_test_report
179
+ return get_test_report(test_id)
180
+
181
+
182
+ @router.post("/test/start")
183
+ async def start_test():
184
+ """开始一次测试,创建测试记录。"""
185
+ import time
186
+ now = int(time.time())
187
+ with get_db() as conn:
188
+ cur = conn.execute(
189
+ "INSERT INTO tests (type, status, created_at) VALUES ('vocab', 'in_progress', ?)",
190
+ (now,)
191
+ )
192
+ test_id = cur.lastrowid
193
+ return {"test_id": test_id}
194
+
195
+
196
+ @router.get("/review-due")
197
+ async def get_review_due(limit: int = 50):
198
+ """获取今天到期复习的词(FSRS 驱动)。"""
199
+ from app.services.srs import get_due_words
200
+ with get_db() as conn:
201
+ words = get_due_words(limit=limit, conn=conn)
202
+ return words
203
+
204
+
205
+ @router.get("/srs-stats")
206
+ async def get_srs_stats():
207
+ """获取 FSRS 统计概览。"""
208
+ from app.services.srs import get_review_stats
209
+ with get_db() as conn:
210
+ return get_review_stats(conn)
app/cli.py ADDED
@@ -0,0 +1,185 @@
1
+ """ReadLoops 命令行入口。
2
+
3
+ 用法:
4
+ readloops 启动服务并自动打开浏览器
5
+ readloops serve 只启动服务(不打开浏览器)
6
+ readloops init 初始化数据库并检查状态
7
+ readloops doctor 检查运行环境是否就绪
8
+ """
9
+ import argparse
10
+ import sys
11
+ import threading
12
+ import webbrowser
13
+
14
+
15
+ def _masked(key):
16
+ if not key:
17
+ return "(未设置)"
18
+ return key[:8] + "..." if len(key) > 8 else "***"
19
+
20
+
21
+ def cmd_init(args):
22
+ """初始化数据库并报告状态。"""
23
+ from app import config
24
+ from app.database import get_db, init_db
25
+
26
+ config.DATA_DIR.mkdir(parents=True, exist_ok=True)
27
+ init_db()
28
+
29
+ with get_db() as conn:
30
+ tables = [r[0] for r in conn.execute(
31
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")]
32
+ words = conn.execute("SELECT COUNT(*) FROM words").fetchone()[0]
33
+ phrases = conn.execute("SELECT COUNT(*) FROM phrases").fetchone()[0]
34
+
35
+ print("ReadLoops 初始化完成")
36
+ print(f" 数据目录:{config.DATA_DIR}")
37
+ print(f" 数据库 :{config.DB_PATH}")
38
+ print(f" 已建表 :{len(tables)} 张")
39
+ print(f" 词库  :{words} 词")
40
+ print(f" 短语库 :{phrases} 条")
41
+
42
+ if words == 0:
43
+ print()
44
+ print("⚠️ 词库为空,暂时无法生成文章。")
45
+ print(" 请导入一份词表,推荐开源词典 ECDICT:")
46
+ print(" https://github.com/skywind3000/ECDICT")
47
+ print(" 导入方法见 README 的「数据准备」章节。")
48
+ return 0
49
+
50
+
51
+ def cmd_doctor(args):
52
+ """检查运行环境。"""
53
+ from app import config
54
+
55
+ ok = True
56
+ print("ReadLoops 环境检查")
57
+ print(f" Python   :{sys.version.split()[0]}")
58
+
59
+ print(f" 数据目录  :{config.DATA_DIR} "
60
+ f"{'✅' if config.DATA_DIR.exists() else '⚠️ 不存在'}")
61
+
62
+ if config.DB_PATH.exists():
63
+ print(f" 数据库   :{config.DB_PATH} ✅")
64
+ try:
65
+ from app.database import get_db
66
+ with get_db() as conn:
67
+ words = conn.execute("SELECT COUNT(*) FROM words").fetchone()[0]
68
+ if words:
69
+ print(f" 词库    :{words} 词 ✅")
70
+ else:
71
+ print(" 词库    :0 词 ⚠️ 需导入词表")
72
+ ok = False
73
+ except Exception as exc: # noqa: BLE001
74
+ print(f" 数据库读取失败:{exc}")
75
+ ok = False
76
+ else:
77
+ print(f" 数据库   :{config.DB_PATH} ⚠️ 不存在(运行 readloops init)")
78
+ ok = False
79
+
80
+ corpus = config.CORPUS_DIR / "真题阅读纯文本" / "all_passages_lazynote.json"
81
+ print(f" 真题语料  :{'✅ 已提供' if corpus.exists() else '— 未提供(可选,仅影响相似度匹配)'}")
82
+
83
+ try:
84
+ from app.database import get_db
85
+ with get_db() as conn:
86
+ row = conn.execute("SELECT value FROM settings WHERE key='ai_api_key'").fetchone()
87
+ print(f" AI Key   :{_masked(row[0] if row else '')}")
88
+ except Exception: # noqa: BLE001
89
+ pass
90
+
91
+ print()
92
+ print("结论:" + ("环境就绪 ✅" if ok else "还需完成上述 ⚠️ 项"))
93
+ return 0 if ok else 1
94
+
95
+
96
+ def cmd_import_dict(args):
97
+ """导入词典 / 词表文件。"""
98
+ from app.services.dict_import import import_dictionary
99
+
100
+ print(f"导入词表:{args.path}")
101
+ if args.level:
102
+ print(f" 仅导入等级:{args.level}")
103
+
104
+ def progress(n):
105
+ print(f" 已导入 {n} 词...")
106
+
107
+ try:
108
+ result = import_dictionary(args.path, level_filter=args.level, on_progress=progress)
109
+ except FileNotFoundError as exc:
110
+ print(f"❌ {exc}")
111
+ return 1
112
+
113
+ from app.database import get_db
114
+ with get_db() as conn:
115
+ total = conn.execute("SELECT COUNT(*) FROM words").fetchone()[0]
116
+
117
+ print()
118
+ print("导入完成")
119
+ print(f" 识别格式 :{result['format']}")
120
+ print(f" 新增   :{result['inserted']} 词")
121
+ print(f" 已存在跳过:{result['skipped']} 词")
122
+ print(f" 词库总量 :{total} 词")
123
+ return 0
124
+
125
+
126
+ def cmd_serve(args):
127
+ """启动服务。"""
128
+ import uvicorn
129
+
130
+ url = f"http://{args.host}:{args.port}"
131
+
132
+ if not args.no_browser:
133
+ threading.Timer(1.5, lambda: webbrowser.open(url)).start()
134
+
135
+ print(f"ReadLoops 运行于 {url}")
136
+ print("按 Ctrl+C 停止")
137
+ uvicorn.run("app.main:app", host=args.host, port=args.port, log_level="info")
138
+ return 0
139
+
140
+
141
+ def _force_utf8_io():
142
+ """Windows 控制台默认代码页(cp1252/GBK 等)可能无法编码中文或符号,
143
+ 统一把标准流重配置为 UTF-8,避免 UnicodeEncodeError。"""
144
+ for stream in (sys.stdout, sys.stderr):
145
+ reconfigure = getattr(stream, "reconfigure", None)
146
+ if reconfigure is not None:
147
+ try:
148
+ reconfigure(encoding="utf-8", errors="replace")
149
+ except (ValueError, OSError):
150
+ pass
151
+
152
+
153
+ def main(argv=None):
154
+ _force_utf8_io()
155
+ parser = argparse.ArgumentParser(
156
+ prog="readloops",
157
+ description="ReadLoops — AI 驱动的英语阅读训练器",
158
+ )
159
+ sub = parser.add_subparsers(dest="command")
160
+
161
+ p_serve = sub.add_parser("serve", help="启动服务(默认命令)")
162
+ p_serve.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)")
163
+ p_serve.add_argument("--port", type=int, default=8000, help="端口(默认 8000)")
164
+ p_serve.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
165
+ p_serve.set_defaults(func=cmd_serve)
166
+
167
+ sub.add_parser("init", help="初始化数据库").set_defaults(func=cmd_init)
168
+ sub.add_parser("doctor", help="检查运行环境").set_defaults(func=cmd_doctor)
169
+
170
+ p_import = sub.add_parser("import-dict", help="导入词典 / 词表文件")
171
+ p_import.add_argument("path", help="文件路径(支持 ECDICT CSV/JSON、纯文本词表)")
172
+ p_import.add_argument("--level", help="只导入指定等级,如 CET4")
173
+ p_import.set_defaults(func=cmd_import_dict)
174
+
175
+ args = parser.parse_args(argv)
176
+
177
+ # 无子命令 → 默认启动服务
178
+ if not getattr(args, "command", None):
179
+ args = parser.parse_args(["serve"] + (argv or []))
180
+
181
+ return args.func(args)
182
+
183
+
184
+ if __name__ == "__main__":
185
+ sys.exit(main())
app/config.py ADDED
@@ -0,0 +1,58 @@
1
+ """全局配置:路径、AI、主题。
2
+
3
+ 数据目录的选取规则(跨平台):
4
+ - 源码模式(项目根下存在 pyproject.toml)→ `<项目根>/data`,便于开发调试
5
+ - 安装模式(pip install)→ 各系统的用户数据目录,避免写入 site-packages
6
+ - 任何时候都可用环境变量 `READLOOPS_DATA_DIR` 覆盖
7
+ """
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ # 包所在目录
13
+ PACKAGE_DIR = Path(__file__).parent
14
+ # 上一级:源码模式下是项目根;安装模式下是 site-packages
15
+ ROOT_DIR = PACKAGE_DIR.parent
16
+
17
+
18
+ def _default_data_dir():
19
+ """按运行模式与操作系统选取可写的数据目录。"""
20
+ # 源码模式:项目根下有 pyproject.toml(该文件不会被安装进 site-packages)
21
+ if (ROOT_DIR / "pyproject.toml").exists():
22
+ return ROOT_DIR / "data"
23
+
24
+ # 安装模式:使用系统约定的用户数据目录
25
+ if sys.platform == "win32":
26
+ base = os.getenv("LOCALAPPDATA") or (Path.home() / "AppData" / "Local")
27
+ return Path(base) / "ReadLoops"
28
+ if sys.platform == "darwin":
29
+ return Path.home() / "Library" / "Application Support" / "ReadLoops"
30
+ # Linux / 其他 Unix
31
+ base = os.getenv("XDG_DATA_HOME") or (Path.home() / ".local" / "share")
32
+ return Path(base) / "readloops"
33
+
34
+
35
+ # 数据目录可用环境变量覆盖(便于测试与多环境部署)
36
+ DATA_DIR = Path(os.getenv("READLOOPS_DATA_DIR") or _default_data_dir())
37
+ CORPUS_DIR = Path(os.getenv("READLOOPS_CORPUS_DIR") or ROOT_DIR / "语料库")
38
+ WEB_DIR = PACKAGE_DIR / "web"
39
+
40
+ DB_PATH = Path(os.getenv("READLOOPS_DB_PATH") or DATA_DIR / "yuedu.db")
41
+ DICT_DB_PATH = DATA_DIR / "ecdict.db"
42
+
43
+ # AI 配置(默认 DeepSeek)
44
+ AI_BASE_URL = os.getenv("AI_BASE_URL", "https://api.deepseek.com")
45
+ AI_API_KEY = os.getenv("AI_API_KEY", "")
46
+ AI_MODEL = os.getenv("AI_MODEL", "deepseek-flash")
47
+
48
+ # FSRS 配置
49
+ FSRS_RETENTION = 0.9
50
+ FSRS_MAX_INTERVAL = 365
51
+
52
+ # 文章参数
53
+ TARGET_WORD_COUNT = 300
54
+ MIN_NEW_WORDS = 6
55
+ MAX_NEW_WORDS = 15
56
+ CALIBRATION_ARTICLES = 5
57
+
58
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
app/database.py ADDED
@@ -0,0 +1,204 @@
1
+ """数据库连接与初始化。
2
+
3
+ SCHEMA 是数据库结构的**单一事实源**:全新安装由 init_db() 直接建出完整结构(10 表)。
4
+ `tools/migrate_db_v2.py` 仅用于升级历史遗留的旧数据库,不再承担"补全结构"的职责。
5
+ """
6
+ import sqlite3
7
+ from contextlib import contextmanager
8
+
9
+ from app.config import DB_PATH
10
+
11
+ SCHEMA = """
12
+ CREATE TABLE IF NOT EXISTS users (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ name TEXT NOT NULL DEFAULT '用户1',
15
+ created_at INTEGER NOT NULL
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS words (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ lemma TEXT NOT NULL,
21
+ text TEXT NOT NULL,
22
+ type TEXT NOT NULL DEFAULT 'word',
23
+ meaning TEXT,
24
+ phonetic TEXT,
25
+ level TEXT DEFAULT 'CET4',
26
+ frequency INTEGER DEFAULT 0,
27
+ status TEXT DEFAULT 'new',
28
+ encounter_count INTEGER DEFAULT 0,
29
+ lookup_count INTEGER DEFAULT 0,
30
+ srs_due INTEGER,
31
+ srs_stability REAL DEFAULT 0,
32
+ srs_difficulty REAL DEFAULT 0,
33
+ srs_state INTEGER DEFAULT 0,
34
+ srs_lapses INTEGER DEFAULT 0,
35
+ srs_reps INTEGER DEFAULT 0,
36
+ srs_last_review INTEGER,
37
+ srs_interval REAL DEFAULT 0,
38
+ exchange TEXT,
39
+ wrong_count INTEGER DEFAULT 0,
40
+ correct_count INTEGER DEFAULT 0,
41
+ last_test_at INTEGER,
42
+ created_at INTEGER NOT NULL,
43
+ updated_at INTEGER NOT NULL
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS word_encounters (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ word_id INTEGER NOT NULL,
49
+ article_id INTEGER,
50
+ context TEXT,
51
+ action TEXT NOT NULL,
52
+ implicit_rating TEXT,
53
+ created_at INTEGER NOT NULL,
54
+ FOREIGN KEY (word_id) REFERENCES words(id)
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS articles (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ title TEXT NOT NULL,
60
+ content TEXT NOT NULL,
61
+ source TEXT DEFAULT 'ai',
62
+ word_count INTEGER DEFAULT 0,
63
+ target_words TEXT DEFAULT '[]',
64
+ new_word_count INTEGER DEFAULT 0,
65
+ difficulty_score REAL,
66
+ reading_time_seconds INTEGER DEFAULT 0,
67
+ created_at INTEGER NOT NULL
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS highlights (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ article_id INTEGER NOT NULL,
73
+ text TEXT NOT NULL,
74
+ word_id INTEGER,
75
+ color TEXT DEFAULT 'yellow',
76
+ created_at INTEGER NOT NULL
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS reading_sessions (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ article_id INTEGER NOT NULL,
82
+ start_time INTEGER NOT NULL,
83
+ end_time INTEGER,
84
+ duration_seconds INTEGER DEFAULT 0,
85
+ lookups INTEGER DEFAULT 0,
86
+ highlights INTEGER DEFAULT 0,
87
+ FOREIGN KEY (article_id) REFERENCES articles(id)
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS tests (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ type TEXT DEFAULT 'vocab',
93
+ status TEXT DEFAULT 'in_progress',
94
+ score INTEGER DEFAULT 0,
95
+ total INTEGER DEFAULT 0,
96
+ created_at INTEGER NOT NULL,
97
+ completed_at INTEGER
98
+ );
99
+
100
+ CREATE TABLE IF NOT EXISTS settings (
101
+ key TEXT PRIMARY KEY,
102
+ value TEXT
103
+ );
104
+
105
+ CREATE TABLE IF NOT EXISTS phrases (
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ text TEXT UNIQUE NOT NULL,
108
+ meaning TEXT,
109
+ frequency INTEGER DEFAULT 0,
110
+ level TEXT DEFAULT 'CET4',
111
+ created_at INTEGER
112
+ );
113
+
114
+ CREATE TABLE IF NOT EXISTS test_questions (
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ test_id INTEGER,
117
+ word_id INTEGER,
118
+ word_text TEXT,
119
+ question_type TEXT,
120
+ question TEXT,
121
+ options TEXT,
122
+ answer TEXT,
123
+ user_answer TEXT,
124
+ is_correct INTEGER,
125
+ reaction_time INTEGER,
126
+ created_at INTEGER
127
+ );
128
+
129
+ CREATE INDEX IF NOT EXISTS idx_words_status ON words(status);
130
+ CREATE INDEX IF NOT EXISTS idx_words_level ON words(level);
131
+ CREATE INDEX IF NOT EXISTS idx_words_lemma ON words(lemma);
132
+ CREATE INDEX IF NOT EXISTS idx_phrases_level ON phrases(level);
133
+ CREATE INDEX IF NOT EXISTS idx_test_questions_test_id ON test_questions(test_id);
134
+ CREATE INDEX IF NOT EXISTS idx_test_questions_word_id ON test_questions(word_id);
135
+ """
136
+
137
+ DEFAULT_SETTINGS = {
138
+ "theme": "dark",
139
+ "font_size": "17",
140
+ "line_height": "1.75",
141
+ "ai_base_url": "https://api.deepseek.com",
142
+ "ai_api_key": "",
143
+ "ai_model": "deepseek-flash",
144
+ }
145
+
146
+
147
+ # 列级迁移:为历史遗留数据库补齐 SCHEMA 中新增的列(幂等,可重复执行)
148
+ # 背景:CREATE TABLE IF NOT EXISTS 不会改动已存在的表,导致旧库缺列、代码报
149
+ # "table X has no column named Y"。这里统一在启动时补齐,避免 schema 漂移。
150
+ COLUMN_MIGRATIONS = {
151
+ "words": {
152
+ "exchange": "TEXT",
153
+ "wrong_count": "INTEGER DEFAULT 0",
154
+ "correct_count": "INTEGER DEFAULT 0",
155
+ "last_test_at": "INTEGER",
156
+ "srs_interval": "REAL DEFAULT 0",
157
+ },
158
+ "tests": {
159
+ "status": "TEXT DEFAULT 'in_progress'",
160
+ "total": "INTEGER DEFAULT 0",
161
+ "completed_at": "INTEGER",
162
+ },
163
+ }
164
+
165
+
166
+ def _ensure_columns(conn):
167
+ """补齐各表缺失的列(幂等)。"""
168
+ for table, columns in COLUMN_MIGRATIONS.items():
169
+ existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
170
+ for name, decl in columns.items():
171
+ if name not in existing:
172
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {decl}")
173
+
174
+
175
+ def init_db():
176
+ """初始化数据库:建表、补齐缺失列、插入默认数据。"""
177
+ import time
178
+ now = int(time.time())
179
+ with get_db() as conn:
180
+ conn.executescript(SCHEMA)
181
+ _ensure_columns(conn)
182
+ # 默认用户
183
+ conn.execute("INSERT OR IGNORE INTO users (id, name, created_at) VALUES (1, '用户1', ?)", (now,))
184
+ # 默认设置
185
+ for key, value in DEFAULT_SETTINGS.items():
186
+ conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", (key, value))
187
+
188
+
189
+ @contextmanager
190
+ def get_db():
191
+ """获取数据库连接,自动提交和关闭。"""
192
+ conn = sqlite3.connect(str(DB_PATH))
193
+ conn.row_factory = sqlite3.Row
194
+ conn.execute("PRAGMA journal_mode=WAL")
195
+ conn.execute("PRAGMA foreign_keys=ON")
196
+ try:
197
+ yield conn
198
+ conn.commit()
199
+ finally:
200
+ conn.close()
201
+
202
+
203
+ # 模块加载时自动初始化
204
+ init_db()