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/__init__.py +0 -0
- app/api/__init__.py +0 -0
- app/api/articles.py +97 -0
- app/api/reading.py +157 -0
- app/api/stats.py +254 -0
- app/api/words.py +210 -0
- app/cli.py +185 -0
- app/config.py +58 -0
- app/database.py +204 -0
- app/main.py +63 -0
- app/models.py +35 -0
- app/services/__init__.py +0 -0
- app/services/ai.py +571 -0
- app/services/corpus.py +78 -0
- app/services/dict_import.py +236 -0
- app/services/similarity.py +209 -0
- app/services/smart_test.py +345 -0
- app/services/srs.py +154 -0
- app/web/css/style.css +2338 -0
- app/web/icon.svg +14 -0
- app/web/index.html +179 -0
- app/web/js/app.js +1923 -0
- app/web/manifest.json +24 -0
- readloops-2.3.0.dist-info/METADATA +272 -0
- readloops-2.3.0.dist-info/RECORD +29 -0
- readloops-2.3.0.dist-info/WHEEL +5 -0
- readloops-2.3.0.dist-info/entry_points.txt +2 -0
- readloops-2.3.0.dist-info/licenses/LICENSE +21 -0
- readloops-2.3.0.dist-info/top_level.txt +1 -0
app/main.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""FastAPI 入口。"""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.responses import FileResponse, HTMLResponse
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
|
|
8
|
+
from app.api import articles, reading, stats, words
|
|
9
|
+
from app.config import WEB_DIR
|
|
10
|
+
|
|
11
|
+
app = FastAPI(title="ReadLoops", version="2.2.1")
|
|
12
|
+
|
|
13
|
+
# 注册路由
|
|
14
|
+
app.include_router(articles.router)
|
|
15
|
+
app.include_router(words.router)
|
|
16
|
+
app.include_router(reading.router)
|
|
17
|
+
app.include_router(stats.router)
|
|
18
|
+
|
|
19
|
+
# 静态文件
|
|
20
|
+
app.mount("/static", StaticFiles(directory=str(WEB_DIR)), name="static")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# 前端资源版本号占位符,如 `app.js?v=37`;由服务端按文件 mtime 动态替换。
|
|
24
|
+
_ASSET_VERSION_RE = re.compile(r"\?v=\d+")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _asset_version() -> str:
|
|
28
|
+
"""以前端资源的最新修改时间作为缓存版本号。
|
|
29
|
+
|
|
30
|
+
这样每次改动 app.js / style.css,URL 上的 ?v= 会自动变化,
|
|
31
|
+
浏览器必定重新拉取,无需手动改版本号——彻底避免
|
|
32
|
+
「改了代码但界面没变」的缓存问题。
|
|
33
|
+
"""
|
|
34
|
+
latest = 0.0
|
|
35
|
+
for path in (WEB_DIR / "js" / "app.js", WEB_DIR / "css" / "style.css"):
|
|
36
|
+
try:
|
|
37
|
+
latest = max(latest, path.stat().st_mtime)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
return str(int(latest))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.get("/")
|
|
44
|
+
async def index():
|
|
45
|
+
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
|
|
46
|
+
html = _ASSET_VERSION_RE.sub(lambda m: f"?v={_asset_version()}", html)
|
|
47
|
+
# no-cache:每次使用前都向服务端校验,确保拿到最新的版本号
|
|
48
|
+
return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.get("/manifest.json")
|
|
52
|
+
async def manifest():
|
|
53
|
+
return FileResponse(str(WEB_DIR / "manifest.json"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.get("/icon.svg")
|
|
57
|
+
async def icon():
|
|
58
|
+
return FileResponse(str(WEB_DIR / "icon.svg"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.get("/api/health")
|
|
62
|
+
async def health():
|
|
63
|
+
return {"status": "ok"}
|
app/models.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""数据模型。"""
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Article:
|
|
8
|
+
id: int | None = None
|
|
9
|
+
title: str = ""
|
|
10
|
+
content: str = ""
|
|
11
|
+
source: str = "ai"
|
|
12
|
+
word_count: int = 0
|
|
13
|
+
target_words: str = "[]"
|
|
14
|
+
new_word_count: int = 0
|
|
15
|
+
difficulty_score: float | None = None
|
|
16
|
+
reading_time_seconds: int = 0
|
|
17
|
+
created_at: int = field(default_factory=lambda: int(time.time()))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Word:
|
|
22
|
+
id: int | None = None
|
|
23
|
+
text: str = ""
|
|
24
|
+
meaning: str = ""
|
|
25
|
+
phonetic: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class WordEncounter:
|
|
30
|
+
id: int | None = None
|
|
31
|
+
word_id: int = 0
|
|
32
|
+
article_id: int | None = None
|
|
33
|
+
context: str = ""
|
|
34
|
+
action: str = "lookup"
|
|
35
|
+
created_at: int = field(default_factory=lambda: int(time.time()))
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/ai.py
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""AI 接口层:文章生成、单词查询。"""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from app.config import AI_API_KEY, AI_BASE_URL, AI_MODEL
|
|
9
|
+
from app.database import get_db
|
|
10
|
+
from app.models import Article, Word
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_settings():
|
|
14
|
+
"""从数据库读取设置,覆盖默认配置。"""
|
|
15
|
+
with get_db() as conn:
|
|
16
|
+
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
|
17
|
+
s = {r["key"]: r["value"] for r in rows}
|
|
18
|
+
return {
|
|
19
|
+
"base_url": s.get("ai_base_url", AI_BASE_URL),
|
|
20
|
+
"api_key": s.get("ai_api_key", AI_API_KEY),
|
|
21
|
+
"model": s.get("ai_model", AI_MODEL),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _chat(messages, max_tokens=1500, temperature=0.7):
|
|
26
|
+
"""调用 AI 聊天接口,关闭思考模式。"""
|
|
27
|
+
cfg = _get_settings()
|
|
28
|
+
if not cfg["api_key"]:
|
|
29
|
+
raise ValueError("未配置 API Key")
|
|
30
|
+
resp = httpx.post(
|
|
31
|
+
f"{cfg['base_url'].rstrip('/')}/chat/completions",
|
|
32
|
+
headers={
|
|
33
|
+
"Authorization": f"Bearer {cfg['api_key']}",
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
},
|
|
36
|
+
json={
|
|
37
|
+
"model": cfg["model"],
|
|
38
|
+
"messages": messages,
|
|
39
|
+
"max_tokens": max_tokens,
|
|
40
|
+
"temperature": temperature,
|
|
41
|
+
"thinking": {"type": "disabled"},
|
|
42
|
+
},
|
|
43
|
+
timeout=60,
|
|
44
|
+
)
|
|
45
|
+
resp.raise_for_status()
|
|
46
|
+
data = resp.json()
|
|
47
|
+
return data["choices"][0]["message"]["content"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract_json(text):
|
|
51
|
+
"""从 AI 返回内容中提取 JSON,处理 markdown 包裹等情况。"""
|
|
52
|
+
text = text.strip()
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(text)
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
pass
|
|
57
|
+
text = re.sub(r"^```(?:json)?\s*", "", text)
|
|
58
|
+
text = re.sub(r"\s*```$", "", text)
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(text)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
pass
|
|
63
|
+
match = re.search(r"\{.*\}", text, re.DOTALL)
|
|
64
|
+
if match:
|
|
65
|
+
return json.loads(match.group())
|
|
66
|
+
raise ValueError(f"无法从 AI 返回中提取 JSON: {text[:200]}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_recent_used_words(limit=50):
|
|
70
|
+
"""获取最近 N 篇文章用过的词,用于避免重复。"""
|
|
71
|
+
with get_db() as conn:
|
|
72
|
+
rows = conn.execute(
|
|
73
|
+
"SELECT target_words FROM articles ORDER BY created_at DESC LIMIT ?",
|
|
74
|
+
(limit,),
|
|
75
|
+
).fetchall()
|
|
76
|
+
used = set()
|
|
77
|
+
for r in rows:
|
|
78
|
+
try:
|
|
79
|
+
words = json.loads(r["target_words"] or "[]")
|
|
80
|
+
used.update(words)
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
return used
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _select_new_words(target_count):
|
|
87
|
+
"""从词库中选择生词(FSRS 驱动)。
|
|
88
|
+
|
|
89
|
+
优先级:
|
|
90
|
+
1. 今天到期复习的词(srs_due <= now)— 最高优先级
|
|
91
|
+
2. 快到期的词(srs_due 在未来3天内)— 高优先级
|
|
92
|
+
3. 记忆强度低的词(srs_stability < 10)— 中优先级
|
|
93
|
+
4. status='target' 的词(查过2次)
|
|
94
|
+
5. status='learning' 的词(生词本)
|
|
95
|
+
6. 从词库选择(优先 CET4、有释义、避免最近重复)
|
|
96
|
+
"""
|
|
97
|
+
import time
|
|
98
|
+
now = int(time.time())
|
|
99
|
+
three_days = now + 3 * 86400
|
|
100
|
+
recent_used = _get_recent_used_words(30) # 最近30篇用过的词不重复
|
|
101
|
+
|
|
102
|
+
with get_db() as conn:
|
|
103
|
+
selected = []
|
|
104
|
+
|
|
105
|
+
# 1. 今天到期复习的词(FSRS 驱动)
|
|
106
|
+
due_rows = conn.execute(
|
|
107
|
+
"SELECT text FROM words WHERE status IN ('learning','target') "
|
|
108
|
+
"AND srs_due <= ? AND srs_stability IS NOT NULL "
|
|
109
|
+
"ORDER BY srs_due ASC LIMIT ?",
|
|
110
|
+
(now, target_count),
|
|
111
|
+
).fetchall()
|
|
112
|
+
selected.extend([r["text"] for r in due_rows])
|
|
113
|
+
|
|
114
|
+
# 2. 快到期的词(未来3天内)
|
|
115
|
+
if len(selected) < target_count:
|
|
116
|
+
exclude = selected
|
|
117
|
+
placeholders = ",".join(["?"] * len(exclude)) if exclude else "''"
|
|
118
|
+
soon_rows = conn.execute(
|
|
119
|
+
f"SELECT text FROM words WHERE status IN ('learning','target') "
|
|
120
|
+
f"AND srs_due > ? AND srs_due <= ? "
|
|
121
|
+
f"AND text NOT IN ({placeholders}) "
|
|
122
|
+
f"ORDER BY srs_due ASC LIMIT ?",
|
|
123
|
+
(now, three_days, *exclude, target_count - len(selected)),
|
|
124
|
+
).fetchall()
|
|
125
|
+
selected.extend([r["text"] for r in soon_rows])
|
|
126
|
+
|
|
127
|
+
# 3. 记忆强度低的词
|
|
128
|
+
if len(selected) < target_count:
|
|
129
|
+
exclude = list(set(selected))
|
|
130
|
+
placeholders = ",".join(["?"] * len(exclude)) if exclude else "''"
|
|
131
|
+
low_stab_rows = conn.execute(
|
|
132
|
+
f"SELECT text FROM words WHERE status IN ('learning','target') "
|
|
133
|
+
f"AND srs_stability < 10 AND srs_stability IS NOT NULL "
|
|
134
|
+
f"AND text NOT IN ({placeholders}) "
|
|
135
|
+
f"ORDER BY srs_stability ASC LIMIT ?",
|
|
136
|
+
(*exclude, target_count - len(selected)),
|
|
137
|
+
).fetchall()
|
|
138
|
+
selected.extend([r["text"] for r in low_stab_rows])
|
|
139
|
+
|
|
140
|
+
# 4. target 词(查过2次的词)
|
|
141
|
+
if len(selected) < target_count:
|
|
142
|
+
exclude = list(set(selected))
|
|
143
|
+
placeholders = ",".join(["?"] * len(exclude)) if exclude else "''"
|
|
144
|
+
target_rows = conn.execute(
|
|
145
|
+
f"SELECT text FROM words WHERE status='target' "
|
|
146
|
+
f"AND text NOT IN ({placeholders}) "
|
|
147
|
+
f"ORDER BY lookup_count DESC LIMIT ?",
|
|
148
|
+
(*exclude, target_count - len(selected)),
|
|
149
|
+
).fetchall()
|
|
150
|
+
selected.extend([r["text"] for r in target_rows])
|
|
151
|
+
|
|
152
|
+
# 5. 生词本中的词
|
|
153
|
+
if len(selected) < target_count:
|
|
154
|
+
exclude = list(set(selected))
|
|
155
|
+
placeholders = ",".join(["?"] * len(exclude)) if exclude else "''"
|
|
156
|
+
learning_rows = conn.execute(
|
|
157
|
+
f"SELECT text FROM words WHERE status='learning' "
|
|
158
|
+
f"AND text NOT IN ({placeholders}) "
|
|
159
|
+
f"ORDER BY lookup_count DESC LIMIT ?",
|
|
160
|
+
(*exclude, target_count - len(selected)),
|
|
161
|
+
).fetchall()
|
|
162
|
+
selected.extend([r["text"] for r in learning_rows])
|
|
163
|
+
|
|
164
|
+
# 6. 从词库选择(优先 CET4 级别、有释义、长度适中、排除最近重复)
|
|
165
|
+
if len(selected) < target_count:
|
|
166
|
+
exclude = list(set(selected) | recent_used)
|
|
167
|
+
placeholders = ",".join(["?"] * len(exclude)) if exclude else "''"
|
|
168
|
+
cet4_rows = conn.execute(
|
|
169
|
+
f"SELECT text FROM words "
|
|
170
|
+
f"WHERE level='CET4' AND meaning IS NOT NULL AND meaning != '' "
|
|
171
|
+
f"AND length(text) BETWEEN 4 AND 10 "
|
|
172
|
+
f"AND text NOT IN ({placeholders}) "
|
|
173
|
+
f"ORDER BY RANDOM() LIMIT ?",
|
|
174
|
+
(*exclude, target_count - len(selected)),
|
|
175
|
+
).fetchall()
|
|
176
|
+
selected.extend([r["text"] for r in cet4_rows])
|
|
177
|
+
|
|
178
|
+
# CET4 不够的话,从全部有释义的词里补
|
|
179
|
+
if len(selected) < target_count:
|
|
180
|
+
exclude2 = list(set(selected) | recent_used)
|
|
181
|
+
placeholders2 = ",".join(["?"] * len(exclude2)) if exclude2 else "''"
|
|
182
|
+
random_rows = conn.execute(
|
|
183
|
+
f"SELECT text FROM words "
|
|
184
|
+
f"WHERE meaning IS NOT NULL AND meaning != '' "
|
|
185
|
+
f"AND length(text) BETWEEN 4 AND 12 "
|
|
186
|
+
f"AND text NOT IN ({placeholders2}) "
|
|
187
|
+
f"ORDER BY RANDOM() LIMIT ?",
|
|
188
|
+
(*exclude2, target_count - len(selected)),
|
|
189
|
+
).fetchall()
|
|
190
|
+
selected.extend([r["text"] for r in random_rows])
|
|
191
|
+
|
|
192
|
+
# 还不够就放宽条件
|
|
193
|
+
if len(selected) < target_count:
|
|
194
|
+
exclude3 = list(set(selected) | recent_used)
|
|
195
|
+
placeholders3 = ",".join(["?"] * len(exclude3)) if exclude3 else "''"
|
|
196
|
+
fallback_rows = conn.execute(
|
|
197
|
+
f"SELECT text FROM words "
|
|
198
|
+
f"WHERE length(text) BETWEEN 3 AND 15 "
|
|
199
|
+
f"AND text NOT IN ({placeholders3}) "
|
|
200
|
+
f"ORDER BY RANDOM() LIMIT ?",
|
|
201
|
+
(*exclude3, target_count - len(selected)),
|
|
202
|
+
).fetchall()
|
|
203
|
+
selected.extend([r["text"] for r in fallback_rows])
|
|
204
|
+
|
|
205
|
+
return selected[:target_count]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _generate_once(topic, opening, ending, new_words):
|
|
209
|
+
"""单次生成文章,不做去重检测。"""
|
|
210
|
+
import json as _json
|
|
211
|
+
import random as _random
|
|
212
|
+
|
|
213
|
+
# 查询目标词的词形变化
|
|
214
|
+
word_families = {}
|
|
215
|
+
with get_db() as conn:
|
|
216
|
+
placeholders = ",".join(["?"] * len(new_words))
|
|
217
|
+
rows = conn.execute(
|
|
218
|
+
f"SELECT text, exchange FROM words WHERE text IN ({placeholders})",
|
|
219
|
+
new_words
|
|
220
|
+
).fetchall()
|
|
221
|
+
for row in rows:
|
|
222
|
+
if row['exchange']:
|
|
223
|
+
try:
|
|
224
|
+
ex = _json.loads(row['exchange'])
|
|
225
|
+
if ex:
|
|
226
|
+
word_families[row['text']] = ex
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
# 从短语库选2-3个目标短语(高频优先,随机选)
|
|
231
|
+
target_phrases = []
|
|
232
|
+
with get_db() as conn:
|
|
233
|
+
phrases = conn.execute(
|
|
234
|
+
"SELECT text FROM phrases WHERE frequency >= 3 ORDER BY frequency DESC LIMIT 50"
|
|
235
|
+
).fetchall()
|
|
236
|
+
if phrases:
|
|
237
|
+
phrase_texts = [p['text'] for p in phrases]
|
|
238
|
+
target_phrases = _random.sample(phrase_texts, min(3, len(phrase_texts)))
|
|
239
|
+
|
|
240
|
+
# 构建词族描述
|
|
241
|
+
family_desc = ""
|
|
242
|
+
if word_families:
|
|
243
|
+
family_parts = []
|
|
244
|
+
for word, forms in word_families.items():
|
|
245
|
+
form_strs = [f"{v}({k})" for k, v in forms.items() if k != 'lemma']
|
|
246
|
+
if form_strs:
|
|
247
|
+
family_parts.append(f"{word}: {', '.join(form_strs[:3])}")
|
|
248
|
+
if family_parts:
|
|
249
|
+
family_desc = "\n".join(family_parts)
|
|
250
|
+
|
|
251
|
+
words_str = ", ".join(new_words)
|
|
252
|
+
phrases_str = ", ".join(target_phrases) if target_phrases else "high school, years ago, long term, social media"
|
|
253
|
+
|
|
254
|
+
prompt = f"""Write a CET-4 style reading passage (~320 words) for a Chinese college student.
|
|
255
|
+
|
|
256
|
+
Topic: {topic}
|
|
257
|
+
Opening: {opening}
|
|
258
|
+
Ending: {ending}
|
|
259
|
+
|
|
260
|
+
=== STYLE REQUIREMENTS (based on analysis of 205 real CET-4 passages) ===
|
|
261
|
+
|
|
262
|
+
TEXT LENGTH & SENTENCES:
|
|
263
|
+
- Target: ~320 words, 15-18 sentences
|
|
264
|
+
- Average sentence length: 20 words (range: 8-35 words)
|
|
265
|
+
- Mix: 18% short sentences (<=10 words), 40% medium (11-20 words), 42% long (>20 words)
|
|
266
|
+
- Use compound sentences (and/but/or/so) in ~50% of sentences
|
|
267
|
+
- Use relative clauses (which/that/who) in ~30% of sentences
|
|
268
|
+
- Use adverbial clauses (because/although/if/when/while) in ~30% of sentences
|
|
269
|
+
- Include at least 2 long complex sentences (>25 words) with nested clauses
|
|
270
|
+
|
|
271
|
+
TOP CONJUNCTIONS TO USE NATURALLY:
|
|
272
|
+
and, that, as, but, or, when, who, if, so, which, because, while
|
|
273
|
+
|
|
274
|
+
VOCABULARY:
|
|
275
|
+
- Average word length: 4.8 letters
|
|
276
|
+
- Use common CET-4 level words
|
|
277
|
+
- Top content words in real passages: people, time, new, work, students, food, study, research, school, women, life, way
|
|
278
|
+
- Common phrases: high school, years ago, long term, young people, social media, climate change, work life, health care
|
|
279
|
+
|
|
280
|
+
WORD FAMILIES (use these word forms naturally in context, not just the base word):
|
|
281
|
+
{family_desc if family_desc else 'Use varied word forms (noun/verb/adjective) of key words naturally.'}
|
|
282
|
+
|
|
283
|
+
TARGET PHRASES (naturally include at least 2 of these):
|
|
284
|
+
{phrases_str}
|
|
285
|
+
|
|
286
|
+
TONE & STYLE:
|
|
287
|
+
- Objective, informative, journalistic tone
|
|
288
|
+
- Include specific numbers, data, or research findings
|
|
289
|
+
- Cite studies, experts, or surveys to support points
|
|
290
|
+
- Discuss real-world social phenomena
|
|
291
|
+
- NO first-person opinions (no "I think", "we should" as personal advice)
|
|
292
|
+
- NO exclamation marks
|
|
293
|
+
- NO emotional or subjective language
|
|
294
|
+
|
|
295
|
+
OPENING (MUST follow one of these patterns):
|
|
296
|
+
- A concrete fact or statistic: "A recent study found that..."
|
|
297
|
+
- A specific scene: "Across the country, more and more people..."
|
|
298
|
+
- A research finding: "Researchers at X university discovered that..."
|
|
299
|
+
- A question: "Why do people...?"
|
|
300
|
+
- A contrast: "Despite..., many people still..."
|
|
301
|
+
|
|
302
|
+
ENDING (MUST follow one of these patterns):
|
|
303
|
+
- A factual statement: "The findings suggest that..."
|
|
304
|
+
- A modal verb: "This may change how we...", "It could lead to..."
|
|
305
|
+
- A mild caveat: "But more research is needed to..."
|
|
306
|
+
|
|
307
|
+
ABSOLUTELY FORBIDDEN:
|
|
308
|
+
- "In today's fast-paced world"
|
|
309
|
+
- "In conclusion", "Therefore", "As we all know"
|
|
310
|
+
- "It is important to note that"
|
|
311
|
+
- "Nowadays", "With the development of society"
|
|
312
|
+
- Any AI-generated cliché or template phrase
|
|
313
|
+
|
|
314
|
+
=== TARGET WORDS ===
|
|
315
|
+
Naturally include these words in context (do NOT force them, do NOT list them): {words_str}
|
|
316
|
+
Use each target word at least once.
|
|
317
|
+
|
|
318
|
+
Return ONLY valid JSON, no markdown, no explanation:
|
|
319
|
+
{{"title": "A concise title (5-8 words, title case)", "content": "The full passage (~320 words)", "new_words": ["word1", "word2"]}}"""
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
raw = _chat([{"role": "user", "content": prompt}], max_tokens=1500, temperature=0.7)
|
|
323
|
+
data = _extract_json(raw)
|
|
324
|
+
except Exception as e:
|
|
325
|
+
print(f"AI 生成失败: {e}")
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
if not data:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
title = data.get("title", "Untitled")
|
|
332
|
+
content = data.get("content", "")
|
|
333
|
+
article_words = data.get("new_words", [])
|
|
334
|
+
|
|
335
|
+
if not content:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
"title": title,
|
|
340
|
+
"content": content,
|
|
341
|
+
"new_words": article_words,
|
|
342
|
+
"topic": topic,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def generate_article(target_new_words=10):
|
|
347
|
+
"""生成一篇英语短文,严格参考四级真题阅读风格。
|
|
348
|
+
加入去重检测:如果与最近文章太相似,换主题重新生成(最多3次)。
|
|
349
|
+
"""
|
|
350
|
+
import random
|
|
351
|
+
|
|
352
|
+
from app.services.similarity import check_duplicate
|
|
353
|
+
|
|
354
|
+
new_words = _select_new_words(target_new_words)
|
|
355
|
+
|
|
356
|
+
# 题材列表
|
|
357
|
+
all_topics = [
|
|
358
|
+
"a social phenomenon or trend observed in modern society",
|
|
359
|
+
"the impact of technology on daily life or work",
|
|
360
|
+
"a health or medical study finding",
|
|
361
|
+
"an education issue or learning method",
|
|
362
|
+
"an environmental concern or solution",
|
|
363
|
+
"a workplace or career trend",
|
|
364
|
+
"a psychological finding about human behavior",
|
|
365
|
+
"a cultural tradition or social change",
|
|
366
|
+
"a small business or community project",
|
|
367
|
+
"a relationship between family members or friends",
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
openings = [
|
|
371
|
+
"Start with a concrete fact or statistic",
|
|
372
|
+
"Start with a specific scene or observation",
|
|
373
|
+
"Start with a research finding or study result",
|
|
374
|
+
"Start with a question that the article answers",
|
|
375
|
+
"Start with a contrast or surprising fact",
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
endings = [
|
|
379
|
+
"End with a factual statement or observation",
|
|
380
|
+
"End with a modal verb (may/could/should) expressing possibility or suggestion",
|
|
381
|
+
"End with a mild contrast or caveat (but/however)",
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
# 获取最近10篇文章用于去重检测
|
|
385
|
+
with get_db() as conn:
|
|
386
|
+
recent_articles = conn.execute(
|
|
387
|
+
"SELECT id, title, content FROM articles ORDER BY created_at DESC LIMIT 10"
|
|
388
|
+
).fetchall()
|
|
389
|
+
|
|
390
|
+
# 最多尝试3次,每次换不同主题
|
|
391
|
+
used_topics = []
|
|
392
|
+
best_result = None
|
|
393
|
+
best_similarity = 1.0
|
|
394
|
+
|
|
395
|
+
for attempt in range(3):
|
|
396
|
+
# 选择一个没用过的主题
|
|
397
|
+
available_topics = [t for t in all_topics if t not in used_topics]
|
|
398
|
+
if not available_topics:
|
|
399
|
+
available_topics = all_topics
|
|
400
|
+
topic = random.choice(available_topics)
|
|
401
|
+
used_topics.append(topic)
|
|
402
|
+
|
|
403
|
+
opening = random.choice(openings)
|
|
404
|
+
ending = random.choice(endings)
|
|
405
|
+
|
|
406
|
+
result = _generate_once(topic, opening, ending, new_words)
|
|
407
|
+
if not result:
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
# 去重检测
|
|
411
|
+
is_dup, sim, sim_title = check_duplicate(result["content"], recent_articles, threshold=0.4)
|
|
412
|
+
|
|
413
|
+
if not is_dup:
|
|
414
|
+
# 不重复,直接用
|
|
415
|
+
best_result = result
|
|
416
|
+
best_similarity = sim
|
|
417
|
+
break
|
|
418
|
+
|
|
419
|
+
# 重复了,记录最好的结果(相似度最低的)
|
|
420
|
+
if sim < best_similarity:
|
|
421
|
+
best_result = result
|
|
422
|
+
best_similarity = sim
|
|
423
|
+
|
|
424
|
+
print(f"第{attempt+1}次生成与「{sim_title}」相似度{sim:.1%},换主题重试")
|
|
425
|
+
|
|
426
|
+
if not best_result:
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
# 保存文章
|
|
430
|
+
title = best_result["title"]
|
|
431
|
+
content = best_result["content"]
|
|
432
|
+
article_words = best_result["new_words"]
|
|
433
|
+
|
|
434
|
+
word_count = len(content.split())
|
|
435
|
+
now = int(time.time())
|
|
436
|
+
|
|
437
|
+
with get_db() as conn:
|
|
438
|
+
cur = conn.execute(
|
|
439
|
+
"INSERT INTO articles (title, content, source, word_count, target_words, new_word_count, created_at) "
|
|
440
|
+
"VALUES (?, ?, 'ai', ?, ?, ?, ?)",
|
|
441
|
+
(title, content, word_count, json.dumps(article_words), len(article_words), now),
|
|
442
|
+
)
|
|
443
|
+
article_id = cur.lastrowid
|
|
444
|
+
|
|
445
|
+
return Article(
|
|
446
|
+
id=article_id,
|
|
447
|
+
title=title,
|
|
448
|
+
content=content,
|
|
449
|
+
word_count=word_count,
|
|
450
|
+
new_word_count=len(article_words),
|
|
451
|
+
created_at=now,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def get_generator(count=20, difficulty="mixed", source="all"):
|
|
456
|
+
"""生成单词测试题目。返回 (word, options, answer) 列表。
|
|
457
|
+
difficulty: easy(高频)/medium(中频)/hard(低频)/mixed(混合)
|
|
458
|
+
source: all(全部词库)/vocab(生词本)/target(测验词)
|
|
459
|
+
"""
|
|
460
|
+
import random
|
|
461
|
+
with get_db() as conn:
|
|
462
|
+
# 按范围筛选正确答案的词
|
|
463
|
+
where = "meaning IS NOT NULL AND meaning != ''"
|
|
464
|
+
params = []
|
|
465
|
+
if source == "vocab":
|
|
466
|
+
where += " AND status = 'learning'"
|
|
467
|
+
elif source == "target":
|
|
468
|
+
where += " AND status = 'target'"
|
|
469
|
+
elif source == "known":
|
|
470
|
+
where += " AND status = 'known'"
|
|
471
|
+
|
|
472
|
+
# 按难度筛选(用单词长度近似:短词简单,长词困难)
|
|
473
|
+
if difficulty == "easy":
|
|
474
|
+
where += " AND length(text) <= 6"
|
|
475
|
+
elif difficulty == "medium":
|
|
476
|
+
where += " AND length(text) BETWEEN 7 AND 9"
|
|
477
|
+
elif difficulty == "hard":
|
|
478
|
+
where += " AND length(text) >= 10"
|
|
479
|
+
|
|
480
|
+
words = conn.execute(
|
|
481
|
+
f"SELECT text, meaning FROM words WHERE {where} ORDER BY RANDOM() LIMIT ?",
|
|
482
|
+
(*params, count * 3),
|
|
483
|
+
).fetchall()
|
|
484
|
+
|
|
485
|
+
# 干扰项从全部有释义的词中选
|
|
486
|
+
all_words = conn.execute(
|
|
487
|
+
"SELECT text, meaning FROM words WHERE meaning IS NOT NULL AND meaning != '' ORDER BY RANDOM() LIMIT ?",
|
|
488
|
+
(count * 5,),
|
|
489
|
+
).fetchall()
|
|
490
|
+
|
|
491
|
+
if len(words) < count:
|
|
492
|
+
count = len(words)
|
|
493
|
+
|
|
494
|
+
result = []
|
|
495
|
+
for i in range(min(count, len(words))):
|
|
496
|
+
correct = words[i]
|
|
497
|
+
# 选3个干扰项
|
|
498
|
+
distractors = random.sample(
|
|
499
|
+
[w for w in all_words if w["text"] != correct["text"]],
|
|
500
|
+
min(3, len(all_words) - 1)
|
|
501
|
+
)
|
|
502
|
+
options = [correct["meaning"]] + [d["meaning"] for d in distractors]
|
|
503
|
+
random.shuffle(options)
|
|
504
|
+
result.append({
|
|
505
|
+
"word": correct["text"],
|
|
506
|
+
"options": options,
|
|
507
|
+
"answer": correct["meaning"],
|
|
508
|
+
})
|
|
509
|
+
return result
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def lookup_word(word):
|
|
513
|
+
"""查询单词释义。"""
|
|
514
|
+
with get_db() as conn:
|
|
515
|
+
row = conn.execute(
|
|
516
|
+
"SELECT id, text, meaning, phonetic FROM words WHERE text = ?", (word,)
|
|
517
|
+
).fetchone()
|
|
518
|
+
if row and row["meaning"]:
|
|
519
|
+
return Word(
|
|
520
|
+
id=row["id"],
|
|
521
|
+
text=row["text"],
|
|
522
|
+
meaning=row["meaning"],
|
|
523
|
+
phonetic=row["phonetic"],
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
prompt = f"""Define the English word "{word}" for a CET-4 learner.
|
|
527
|
+
Return ONLY valid JSON:
|
|
528
|
+
{{"word": "{word}", "phonetic": "/音标/", "meaning": "中文释义,简短准确", "example": "一个例句"}}"""
|
|
529
|
+
try:
|
|
530
|
+
raw = _chat([{"role": "user", "content": prompt}], max_tokens=300, temperature=0.3)
|
|
531
|
+
data = _extract_json(raw)
|
|
532
|
+
meaning = data.get("meaning", "")
|
|
533
|
+
phonetic = data.get("phonetic", "")
|
|
534
|
+
with get_db() as conn:
|
|
535
|
+
existing = conn.execute("SELECT id FROM words WHERE text = ?", (word,)).fetchone()
|
|
536
|
+
now = int(time.time())
|
|
537
|
+
if existing:
|
|
538
|
+
conn.execute(
|
|
539
|
+
"UPDATE words SET meaning=?, phonetic=?, updated_at=? WHERE id=?",
|
|
540
|
+
(meaning, phonetic, now, existing["id"]),
|
|
541
|
+
)
|
|
542
|
+
wid = existing["id"]
|
|
543
|
+
else:
|
|
544
|
+
cur = conn.execute(
|
|
545
|
+
"INSERT INTO words (lemma, text, type, meaning, phonetic, level, created_at, updated_at) "
|
|
546
|
+
"VALUES (?, ?, 'word', ?, ?, 'CET4', ?, ?)",
|
|
547
|
+
(word, word, meaning, phonetic, now, now),
|
|
548
|
+
)
|
|
549
|
+
wid = cur.lastrowid
|
|
550
|
+
return Word(id=wid, text=word, meaning=meaning, phonetic=phonetic)
|
|
551
|
+
except Exception as e:
|
|
552
|
+
print(f"查词失败: {e}")
|
|
553
|
+
return Word(id=None, text=word, meaning="查询失败", phonetic="")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def translate_sentence(text):
|
|
557
|
+
"""翻译英文句子 / 短语为中文。
|
|
558
|
+
|
|
559
|
+
划词选中多个单词时走这里,而不是查词典
|
|
560
|
+
(词典查不到整句,之前会直接显示「查询失败」)。
|
|
561
|
+
"""
|
|
562
|
+
prompt = f"""Translate the following English text into natural, fluent Chinese.
|
|
563
|
+
Return ONLY the translation itself — no explanation, no quotes, no pinyin.
|
|
564
|
+
|
|
565
|
+
Text: {text}"""
|
|
566
|
+
try:
|
|
567
|
+
result = _chat([{"role": "user", "content": prompt}], max_tokens=500, temperature=0.3)
|
|
568
|
+
return (result or "").strip().strip('"').strip("'")
|
|
569
|
+
except Exception as e:
|
|
570
|
+
print(f"翻译失败: {e}")
|
|
571
|
+
return ""
|