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/services/corpus.py ADDED
@@ -0,0 +1,78 @@
1
+ """语料库导入:四级/六级词表导入 words 表。"""
2
+ import re
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from .. import config
7
+ from ..database import get_db
8
+
9
+
10
+ def parse_word_line(line: str):
11
+ """解析词表行:word [phonetic] pos.meaning"""
12
+ line = line.strip()
13
+ if not line or len(line) <= 2:
14
+ return None
15
+ if re.match(r'^[A-Z]$', line):
16
+ return None
17
+ phonetic = ""
18
+ phonetic_match = re.search(r'\[([^\]]+)\]', line)
19
+ if phonetic_match:
20
+ phonetic = phonetic_match.group(1)
21
+ line = line[:phonetic_match.start()] + line[phonetic_match.end():]
22
+ parts = line.split(None, 1)
23
+ if len(parts) < 2:
24
+ return None
25
+ word = parts[0].strip()
26
+ meaning = parts[1].strip() if len(parts) > 1 else ""
27
+ if not word or not re.match(r'^[a-zA-Z]', word):
28
+ return None
29
+ return word.lower(), word, phonetic, meaning
30
+
31
+ def import_word_list(filepath: str, level: str = "CET4") -> int:
32
+ """导入词表文件,返回导入数量。"""
33
+ path = Path(filepath)
34
+ if not path.exists():
35
+ return 0
36
+ now = int(time.time())
37
+ count = 0
38
+ with open(path, encoding="utf-8-sig") as f:
39
+ lines = f.readlines()
40
+ with get_db() as conn:
41
+ for line in lines:
42
+ parsed = parse_word_line(line)
43
+ if not parsed:
44
+ continue
45
+ lemma, text, phonetic, meaning = parsed
46
+ existing = conn.execute("SELECT id FROM words WHERE lemma=?", (lemma,)).fetchone()
47
+ if existing:
48
+ continue
49
+ conn.execute("""INSERT INTO words
50
+ (lemma, text, type, meaning, phonetic, level, status, created_at, updated_at)
51
+ VALUES (?, ?, 'word', ?, ?, ?, 'new', ?, ?)""",
52
+ (lemma, text, meaning, phonetic, level, now, now))
53
+ count += 1
54
+ return count
55
+
56
+ def import_cet4_words() -> int:
57
+ """导入四级词表。"""
58
+ path = config.CORPUS_DIR / "词表" / "CET4_大纲词表.txt"
59
+ return import_word_list(str(path), "CET4")
60
+
61
+ def import_cet6_words() -> int:
62
+ """导入六级词表。"""
63
+ path = config.CORPUS_DIR / "词表" / "CET6_大纲词表.txt"
64
+ return import_word_list(str(path), "CET6")
65
+
66
+ def lookup_local_dict(word: str) -> dict:
67
+ """从本地 words 表查询单词释义。"""
68
+ with get_db() as conn:
69
+ row = conn.execute("SELECT * FROM words WHERE lemma=?", (word.lower(),)).fetchone()
70
+ if row:
71
+ return {
72
+ "word": row["text"],
73
+ "phonetic": row["phonetic"] or "",
74
+ "meaning": row["meaning"] or "",
75
+ "in_vocab": True,
76
+ "word_id": row["id"],
77
+ }
78
+ return {"word": word, "phonetic": "", "meaning": "", "in_vocab": False, "word_id": None}
@@ -0,0 +1,236 @@
1
+ """词典导入:支持 ECDICT(CSV / JSON)与纯文本词表。
2
+
3
+ 设计要点:
4
+ - 流式读取,避免把整份词典读进内存(ECDICT 约 70MB / 77 万词)
5
+ - 先一次性载入已有 lemma 集合,再批量插入,避免逐行查询(O(n²))
6
+ - 兼容多种来源格式,自动识别
7
+ """
8
+ import csv
9
+ import json
10
+ import re
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from ..database import get_db
15
+
16
+ # ECDICT exchange 字段的类型码映射
17
+ EXCHANGE_TYPES = {
18
+ "0": "lemma", # 原型
19
+ "1": "plural", # 复数
20
+ "2": "ing", # 现在分词
21
+ "3": "third", # 第三人称单数
22
+ "4": "done", # 过去分词
23
+ "5": "er", # 比较级
24
+ "6": "est", # 最高级
25
+ "s": "plural",
26
+ "i": "ing",
27
+ "p": "past",
28
+ "d": "done",
29
+ "r": "er",
30
+ "t": "est",
31
+ }
32
+
33
+ # ECDICT tag 字段 → 项目 level(按优先级匹配)
34
+ LEVEL_TAGS = [
35
+ ("cet4", "CET4"),
36
+ ("cet6", "CET6"),
37
+ ("ky", "KY"),
38
+ ("gk", "GK"),
39
+ ("zk", "ZK"),
40
+ ]
41
+
42
+ BATCH_SIZE = 2000
43
+
44
+
45
+ def parse_exchange(value):
46
+ """把 ECDICT 的 exchange 字段解析为 {类型: 词形}。"""
47
+ if not value or value == "/":
48
+ return {}
49
+ result = {}
50
+ for part in value.split("/"):
51
+ if ":" not in part:
52
+ continue
53
+ code, form = part.split(":", 1)
54
+ form = form.strip()
55
+ if form:
56
+ result[EXCHANGE_TYPES.get(code, code)] = form
57
+ return result
58
+
59
+
60
+ def level_from_tag(tag):
61
+ """从 ECDICT 的 tag 字段推断词表等级。"""
62
+ if not tag:
63
+ return "OTHER"
64
+ low = tag.lower()
65
+ for key, level in LEVEL_TAGS:
66
+ if key in low:
67
+ return level
68
+ return "OTHER"
69
+
70
+
71
+ def detect_format(path):
72
+ """识别词表文件格式:ecdict_csv / json / text。"""
73
+ path = Path(path)
74
+ if path.suffix.lower() == ".json":
75
+ return "json"
76
+ if path.suffix.lower() == ".csv":
77
+ return "ecdict_csv"
78
+ # 未知后缀:看首行像不像带表头的 CSV
79
+ with open(path, encoding="utf-8-sig", errors="replace") as f:
80
+ head = f.readline()
81
+ if "," in head and "word" in head.lower():
82
+ return "ecdict_csv"
83
+ return "text"
84
+
85
+
86
+ def _iter_ecdict_csv(path):
87
+ """流式产出 ECDICT CSV 中的行。"""
88
+ with open(path, encoding="utf-8-sig", errors="replace", newline="") as f:
89
+ reader = csv.DictReader(f)
90
+ for row in reader:
91
+ word = (row.get("word") or "").strip()
92
+ if not word:
93
+ continue
94
+ yield {
95
+ "lemma": word.lower(),
96
+ "text": word,
97
+ "phonetic": (row.get("phonetic") or "").strip(),
98
+ "meaning": (row.get("translation") or row.get("definition") or "").strip(),
99
+ "level": level_from_tag(row.get("tag")),
100
+ "exchange": parse_exchange(row.get("exchange")),
101
+ "frequency": _to_int(row.get("frq")) or _to_int(row.get("bnc")) or 0,
102
+ }
103
+
104
+
105
+ def _iter_json(path):
106
+ """产出 JSON 词表行(兼容 list[dict] 与 dict 包裹)。"""
107
+ data = json.loads(Path(path).read_text(encoding="utf-8-sig"))
108
+ if isinstance(data, dict):
109
+ data = data.get("words") or data.get("data") or list(data.values())
110
+ for row in data:
111
+ if not isinstance(row, dict):
112
+ continue
113
+ word = (row.get("word") or row.get("text") or row.get("lemma") or "").strip()
114
+ if not word:
115
+ continue
116
+ yield {
117
+ "lemma": word.lower(),
118
+ "text": word,
119
+ "phonetic": (row.get("phonetic") or "").strip(),
120
+ "meaning": (row.get("translation") or row.get("meaning")
121
+ or row.get("definition") or "").strip(),
122
+ "level": row.get("level") or level_from_tag(row.get("tag")),
123
+ "exchange": (row.get("exchange") if isinstance(row.get("exchange"), dict)
124
+ else parse_exchange(row.get("exchange"))),
125
+ "frequency": _to_int(row.get("frq")) or _to_int(row.get("frequency")) or 0,
126
+ }
127
+
128
+
129
+ def _iter_text(path):
130
+ """产出纯文本词表行,格式:word [phonetic] pos.meaning"""
131
+ with open(path, encoding="utf-8-sig", errors="replace") as f:
132
+ for line in f:
133
+ line = line.strip()
134
+ if not line or len(line) <= 2 or re.match(r"^[A-Z]$", line):
135
+ continue
136
+ phonetic = ""
137
+ m = re.search(r"\[([^\]]+)\]", line)
138
+ if m:
139
+ phonetic = m.group(1)
140
+ line = line[:m.start()] + line[m.end():]
141
+ parts = line.split(None, 1)
142
+ if len(parts) < 2 or not re.match(r"^[a-zA-Z]", parts[0]):
143
+ continue
144
+ word = parts[0].strip()
145
+ yield {
146
+ "lemma": word.lower(),
147
+ "text": word,
148
+ "phonetic": phonetic,
149
+ "meaning": parts[1].strip(),
150
+ "level": "OTHER",
151
+ "exchange": {},
152
+ "frequency": 0,
153
+ }
154
+
155
+
156
+ def _to_int(value):
157
+ try:
158
+ return int(value)
159
+ except (TypeError, ValueError):
160
+ return 0
161
+
162
+
163
+ _ITERATORS = {
164
+ "ecdict_csv": _iter_ecdict_csv,
165
+ "json": _iter_json,
166
+ "text": _iter_text,
167
+ }
168
+
169
+
170
+ def import_dictionary(path, level_filter=None, on_progress=None):
171
+ """导入词典文件。
172
+
173
+ path: 文件路径
174
+ level_filter: 只导入指定等级(如 'CET4'),None 表示全部
175
+ on_progress: 回调 fn(已导入数),用于打印进度
176
+
177
+ 返回 {"format", "inserted", "skipped", "total"}
178
+ """
179
+ path = Path(path)
180
+ if not path.exists():
181
+ raise FileNotFoundError(f"找不到文件:{path}")
182
+
183
+ fmt = detect_format(path)
184
+ iterator = _ITERATORS[fmt]
185
+
186
+ inserted = skipped = 0
187
+ batch = []
188
+ now = int(time.time())
189
+
190
+ with get_db() as conn:
191
+ # 一次性载入已有词,避免逐行查询
192
+ existing = {r[0] for r in conn.execute("SELECT lemma FROM words")}
193
+
194
+ for row in iterator(path):
195
+ lemma = row["lemma"]
196
+ if not lemma or not re.match(r"^[a-z]", lemma):
197
+ continue
198
+ if level_filter and row["level"] != level_filter:
199
+ continue
200
+ if lemma in existing:
201
+ skipped += 1
202
+ continue
203
+ existing.add(lemma)
204
+ batch.append((
205
+ lemma,
206
+ row["text"] or lemma,
207
+ row["meaning"],
208
+ row["phonetic"],
209
+ row["level"],
210
+ json.dumps(row["exchange"], ensure_ascii=False) if row["exchange"] else None,
211
+ row["frequency"],
212
+ now,
213
+ now,
214
+ ))
215
+ if len(batch) >= BATCH_SIZE:
216
+ _flush(conn, batch)
217
+ inserted += len(batch)
218
+ batch.clear()
219
+ if on_progress:
220
+ on_progress(inserted)
221
+
222
+ if batch:
223
+ _flush(conn, batch)
224
+ inserted += len(batch)
225
+
226
+ return {"format": fmt, "inserted": inserted, "skipped": skipped}
227
+
228
+
229
+ def _flush(conn, batch):
230
+ conn.executemany(
231
+ "INSERT OR IGNORE INTO words "
232
+ "(lemma, text, type, meaning, phonetic, level, exchange, frequency, "
233
+ " status, created_at, updated_at) "
234
+ "VALUES (?, ?, 'word', ?, ?, ?, ?, ?, 'new', ?, ?)",
235
+ batch,
236
+ )
@@ -0,0 +1,209 @@
1
+ """文章相似度计算模块。
2
+ 纯 Python 实现 TF-IDF + 余弦相似度,零外部依赖。
3
+ 用于:文章去重、同质化检测、真题相似度匹配、高频词提取。
4
+ """
5
+ import math
6
+ import re
7
+ from collections import Counter, defaultdict
8
+
9
+ from app import config
10
+ from app.database import get_db
11
+
12
+ # 英语停用词
13
+ STOPWORDS = set("""
14
+ a an the and or but if then else when while although though because since as
15
+ of in on at to for with by from about into over under after before between
16
+ is are was were be been being have has had do does did will would could should
17
+ may might can shall it its this that these those i you he she we they me him
18
+ her us them my your his our their not no so too very just also only own same
19
+ such any all each both few more most other some than them what which who whom
20
+ whose where why how there here up down out off on again once further
21
+ """.split())
22
+
23
+
24
+ def tokenize(text):
25
+ """分词:转小写,提取单词,过滤停用词和短词。"""
26
+ words = re.findall(r'[a-zA-Z]+', text.lower())
27
+ return [w for w in words if w not in STOPWORDS and len(w) > 2]
28
+
29
+
30
+ def get_word_freq(text):
31
+ """获取词频统计。"""
32
+ return Counter(tokenize(text))
33
+
34
+
35
+ def get_top_words(text, n=10):
36
+ """获取高频词 TOP N。"""
37
+ freq = get_word_freq(text)
38
+ return freq.most_common(n)
39
+
40
+
41
+ def build_tfidf_vectors(documents):
42
+ """构建 TF-IDF 向量。
43
+ documents: list of (doc_id, text)
44
+ returns: dict of doc_id -> {word: tfidf_weight}
45
+ """
46
+ # 计算文档频率(DF)
47
+ doc_freq = defaultdict(int)
48
+ doc_tokens = {}
49
+
50
+ for doc_id, text in documents:
51
+ tokens = set(tokenize(text))
52
+ doc_tokens[doc_id] = tokens
53
+ for word in tokens:
54
+ doc_freq[word] += 1
55
+
56
+ n_docs = len(documents)
57
+ vectors = {}
58
+
59
+ for doc_id, text in documents:
60
+ tokens = tokenize(text)
61
+ tf = Counter(tokens)
62
+ vector = {}
63
+ for word, count in tf.items():
64
+ # TF = 词频 / 总词数
65
+ tf_weight = count / len(tokens) if tokens else 0
66
+ # IDF = log(总文档数 / 文档频率)
67
+ idf_weight = math.log(n_docs / (doc_freq[word] + 1))
68
+ vector[word] = tf_weight * idf_weight
69
+ vectors[doc_id] = vector
70
+
71
+ return vectors
72
+
73
+
74
+ def cosine_similarity(vec1, vec2):
75
+ """计算两个向量的余弦相似度。"""
76
+ # 找共同词
77
+ common = set(vec1.keys()) & set(vec2.keys())
78
+ if not common:
79
+ return 0.0
80
+
81
+ dot_product = sum(vec1[w] * vec2[w] for w in common)
82
+ norm1 = math.sqrt(sum(v ** 2 for v in vec1.values()))
83
+ norm2 = math.sqrt(sum(v ** 2 for v in vec2.values()))
84
+
85
+ if norm1 == 0 or norm2 == 0:
86
+ return 0.0
87
+
88
+ return dot_product / (norm1 * norm2)
89
+
90
+
91
+ def get_article_similarity(article_text, other_articles, top_n=3):
92
+ """计算一篇文章与其他文章的相似度,返回 TOP N。
93
+ other_articles: list of (id, title, text)
94
+ returns: list of (id, title, similarity_score)
95
+ """
96
+ if not other_articles:
97
+ return []
98
+
99
+ # 构建所有文档的 TF-IDF 向量(包含目标文章)
100
+ all_docs = [('target', article_text)] + [(str(a[0]), a[2]) for a in other_articles]
101
+ vectors = build_tfidf_vectors(all_docs)
102
+
103
+ target_vec = vectors.get('target', {})
104
+ similarities = []
105
+
106
+ for a in other_articles:
107
+ doc_id = str(a[0])
108
+ if doc_id in vectors:
109
+ sim = cosine_similarity(target_vec, vectors[doc_id])
110
+ similarities.append((a[0], a[1], sim))
111
+
112
+ # 按相似度降序排序
113
+ similarities.sort(key=lambda x: x[2], reverse=True)
114
+ return similarities[:top_n]
115
+
116
+
117
+ def get_exam_similarity(article_text, top_n=3):
118
+ """计算文章与真题的相似度,返回 TOP N 最相近的真题。"""
119
+ return _load_exam_from_file(article_text, top_n)
120
+
121
+
122
+ def _load_exam_from_file(article_text, top_n=3):
123
+ """从语料库文件加载真题并计算相似度。
124
+
125
+ 真题语料受版权保护,不随仓库分发,需用户自备。默认路径:
126
+ <项目根>/语料库/真题阅读纯文本/all_passages_lazynote.json
127
+ 可用环境变量 READLOOPS_CORPUS_FILE 覆盖。文件不存在时返回空列表。
128
+ """
129
+ import json
130
+ import os
131
+
132
+ exam_file = os.getenv('READLOOPS_CORPUS_FILE') or str(
133
+ config.CORPUS_DIR / '真题阅读纯文本' / 'all_passages_lazynote.json'
134
+ )
135
+ if not os.path.exists(exam_file):
136
+ return []
137
+
138
+ with open(exam_file, 'r', encoding='utf-8') as f:
139
+ passages = json.load(f)
140
+
141
+ # 只取仔细阅读(Section C),约350词,和生成文章长度接近
142
+ exam_articles = []
143
+ for p in passages:
144
+ if 'Section C' in p.get('source', ''):
145
+ title = p.get('title', '') or p.get('source', '')
146
+ exam_articles.append((p.get('source', ''), title, p.get('text', '')))
147
+
148
+ return get_article_similarity(article_text, exam_articles, top_n)
149
+
150
+
151
+ def check_duplicate(article_text, recent_articles, threshold=0.4):
152
+ """检查文章是否与最近文章重复或同质化。
153
+ 返回 (is_duplicate, max_similarity, most_similar_title)
154
+ """
155
+ if not recent_articles:
156
+ return False, 0.0, ''
157
+
158
+ other = [(a['id'], a['title'] or '文章', a['content'] or '') for a in recent_articles]
159
+ similarities = get_article_similarity(article_text, other, top_n=1)
160
+
161
+ if similarities:
162
+ _, title, sim = similarities[0]
163
+ return sim >= threshold, sim, title
164
+
165
+ return False, 0.0, ''
166
+
167
+
168
+ def get_article_details(article_id):
169
+ """获取文章详情:高频词、目标生词、真题相似度。"""
170
+ with get_db() as conn:
171
+ article = conn.execute(
172
+ "SELECT id, title, content, target_words, created_at FROM articles WHERE id=?",
173
+ (article_id,)
174
+ ).fetchone()
175
+
176
+ if not article:
177
+ return None
178
+
179
+ content = article['content'] or ''
180
+ target_words = []
181
+ if article['target_words']:
182
+ try:
183
+ import json
184
+ target_words = json.loads(article['target_words'])
185
+ except Exception:
186
+ pass
187
+
188
+ # 高频词
189
+ top_words = get_top_words(content, n=10)
190
+
191
+ # 真题相似度
192
+ exam_sim = get_exam_similarity(content, top_n=3)
193
+
194
+ # 词数
195
+ word_count = len(re.findall(r'[a-zA-Z]+', content))
196
+
197
+ return {
198
+ 'id': article['id'],
199
+ 'title': article['title'],
200
+ 'content': content,
201
+ 'word_count': word_count,
202
+ 'created_at': article['created_at'],
203
+ 'target_words': target_words,
204
+ 'top_words': top_words,
205
+ 'exam_similarity': [
206
+ {'id': s[0], 'title': s[1], 'similarity': round(s[2] * 100, 1)}
207
+ for s in exam_sim
208
+ ],
209
+ }