readloops 2.3.0__tar.gz → 2.6.3__tar.gz
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.
- {readloops-2.3.0 → readloops-2.6.3}/PKG-INFO +16 -3
- {readloops-2.3.0 → readloops-2.6.3}/README.md +16 -3
- {readloops-2.3.0 → readloops-2.6.3}/app/api/articles.py +19 -2
- readloops-2.6.3/app/api/books.py +59 -0
- readloops-2.6.3/app/api/graph.py +46 -0
- readloops-2.6.3/app/api/materials.py +88 -0
- readloops-2.6.3/app/api/placement.py +38 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/api/reading.py +3 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/api/stats.py +55 -1
- readloops-2.6.3/app/api/words.py +341 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/cli.py +6 -3
- {readloops-2.3.0 → readloops-2.6.3}/app/config.py +9 -0
- readloops-2.6.3/app/database.py +384 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/main.py +30 -4
- {readloops-2.3.0 → readloops-2.6.3}/app/services/ai.py +207 -169
- readloops-2.6.3/app/services/bookshelf.py +253 -0
- readloops-2.6.3/app/services/distill.py +265 -0
- readloops-2.6.3/app/services/encounter.py +165 -0
- readloops-2.6.3/app/services/graph.py +425 -0
- readloops-2.6.3/app/services/lexicon.py +144 -0
- readloops-2.6.3/app/services/mastery.py +343 -0
- readloops-2.6.3/app/services/materials.py +340 -0
- readloops-2.6.3/app/services/placement.py +331 -0
- readloops-2.6.3/app/services/selection.py +248 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/services/smart_test.py +39 -13
- readloops-2.6.3/app/services/vocab_gap.py +291 -0
- readloops-2.6.3/app/web/apple-touch-icon.png +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/web/css/style.css +784 -12
- readloops-2.6.3/app/web/favicon-16.png +0 -0
- readloops-2.6.3/app/web/favicon-32.png +0 -0
- readloops-2.6.3/app/web/favicon.ico +0 -0
- readloops-2.6.3/app/web/icon-192.png +0 -0
- readloops-2.6.3/app/web/icon-512.png +0 -0
- readloops-2.6.3/app/web/icon-maskable-512.png +0 -0
- readloops-2.6.3/app/web/icon.svg +12 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/web/index.html +23 -5
- {readloops-2.3.0 → readloops-2.6.3}/app/web/js/app.js +1461 -50
- readloops-2.6.3/app/web/manifest.json +36 -0
- {readloops-2.3.0 → readloops-2.6.3}/pyproject.toml +1 -1
- {readloops-2.3.0 → readloops-2.6.3}/readloops.egg-info/PKG-INFO +16 -3
- {readloops-2.3.0 → readloops-2.6.3}/readloops.egg-info/SOURCES.txt +27 -1
- {readloops-2.3.0 → readloops-2.6.3}/tests/test_database.py +2 -2
- readloops-2.6.3/tests/test_encounter.py +174 -0
- readloops-2.6.3/tests/test_mastery.py +454 -0
- readloops-2.6.3/tests/test_placement.py +240 -0
- readloops-2.6.3/tests/test_style_prompt.py +122 -0
- readloops-2.6.3/tests/test_vocab_gap.py +251 -0
- readloops-2.3.0/app/api/words.py +0 -210
- readloops-2.3.0/app/database.py +0 -204
- readloops-2.3.0/app/web/icon.svg +0 -14
- readloops-2.3.0/app/web/manifest.json +0 -24
- {readloops-2.3.0 → readloops-2.6.3}/LICENSE +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/__init__.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/api/__init__.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/models.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/services/__init__.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/services/corpus.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/services/dict_import.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/services/similarity.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/app/services/srs.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/readloops.egg-info/dependency_links.txt +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/readloops.egg-info/entry_points.txt +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/readloops.egg-info/requires.txt +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/readloops.egg-info/top_level.txt +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/setup.cfg +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/tests/test_api_endpoints.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/tests/test_cli.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/tests/test_similarity.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/tests/test_smoke.py +0 -0
- {readloops-2.3.0 → readloops-2.6.3}/tests/test_srs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: readloops
|
|
3
|
-
Version: 2.3
|
|
3
|
+
Version: 2.6.3
|
|
4
4
|
Summary: AI 驱动的英语阅读训练器:可理解输入 + FSRS 间隔重复
|
|
5
5
|
Author: Fangyuan2711
|
|
6
6
|
License: MIT
|
|
@@ -38,9 +38,22 @@ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
|
38
38
|
Requires-Dist: pre-commit>=3.7; extra == "dev"
|
|
39
39
|
Dynamic: license-file
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
<p align="center">
|
|
42
|
+
<img src="https://raw.githubusercontent.com/pekoqq/ReadLoops/main/assets/logo.png" width="104" alt="ReadLoops logo">
|
|
43
|
+
</p>
|
|
42
44
|
|
|
43
|
-
>
|
|
45
|
+
<h1 align="center">ReadLoops</h1>
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<a href="https://pypi.org/project/readloops/"><img alt="PyPI" src="https://img.shields.io/pypi/v/readloops"></a>
|
|
49
|
+
<a href="https://pypi.org/project/readloops/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/readloops"></a>
|
|
50
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green"></a>
|
|
51
|
+
<a href="https://github.com/pekoqq/ReadLoops/releases"><img alt="Release" src="https://img.shields.io/github/v/release/pekoqq/ReadLoops"></a>
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
<p align="center">
|
|
55
|
+
AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
|
|
56
|
+
</p>
|
|
44
57
|
|
|
45
58
|
## 它解决什么问题
|
|
46
59
|
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
>
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/pekoqq/ReadLoops/main/assets/logo.png" width="104" alt="ReadLoops logo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">ReadLoops</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://pypi.org/project/readloops/"><img alt="PyPI" src="https://img.shields.io/pypi/v/readloops"></a>
|
|
9
|
+
<a href="https://pypi.org/project/readloops/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/readloops"></a>
|
|
10
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green"></a>
|
|
11
|
+
<a href="https://github.com/pekoqq/ReadLoops/releases"><img alt="Release" src="https://img.shields.io/github/v/release/pekoqq/ReadLoops"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
AI 驱动的英语阅读训练器。基于「可理解输入(i+1)」+ 阅读驱动的 FSRS 间隔重复,面向大学英语四级(进阶六级)。
|
|
16
|
+
</p>
|
|
4
17
|
|
|
5
18
|
## 它解决什么问题
|
|
6
19
|
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
from fastapi import APIRouter, HTTPException
|
|
5
5
|
|
|
6
6
|
from app.database import get_db
|
|
7
|
+
from app.services import encounter, mastery
|
|
7
8
|
from app.services.ai import generate_article
|
|
8
9
|
|
|
9
10
|
router = APIRouter(prefix="/api/articles", tags=["articles"])
|
|
@@ -15,6 +16,15 @@ async def generate():
|
|
|
15
16
|
article = generate_article()
|
|
16
17
|
if not article:
|
|
17
18
|
raise HTTPException(status_code=500, detail="无法生成文章,请检查 AI 配置")
|
|
19
|
+
targets = json.loads(article.target_words) if article.target_words else []
|
|
20
|
+
# 生成即记「遇见」:方法的要害是「在变化语境中反复遇见」,而系统此前只在
|
|
21
|
+
# 用户查词时才计数 —— 看到了但没查就完全不算,重遇统计永远起不来。
|
|
22
|
+
try:
|
|
23
|
+
encounter.record_article(article.id, article.content, targets)
|
|
24
|
+
# 遇见改变了识别层的证据,立刻重算(只算这一篇涉及到的词)
|
|
25
|
+
mastery.refresh()
|
|
26
|
+
except Exception:
|
|
27
|
+
pass # 记录失败不能影响文章生成
|
|
18
28
|
return {
|
|
19
29
|
"id": article.id,
|
|
20
30
|
"title": article.title,
|
|
@@ -22,7 +32,9 @@ async def generate():
|
|
|
22
32
|
"source": article.source,
|
|
23
33
|
"word_count": article.word_count,
|
|
24
34
|
"new_word_count": article.new_word_count,
|
|
25
|
-
"target_words":
|
|
35
|
+
"target_words": targets,
|
|
36
|
+
# 短语单列出来,前端可以分开展示「本篇补的单词 / 短语」
|
|
37
|
+
"target_phrases": [t for t in targets if " " in t],
|
|
26
38
|
"created_at": article.created_at,
|
|
27
39
|
}
|
|
28
40
|
|
|
@@ -93,5 +105,10 @@ async def delete_article(article_id: int):
|
|
|
93
105
|
# 先删除关联数据,再删除文章本身(避免外键约束失败)
|
|
94
106
|
conn.execute("DELETE FROM highlights WHERE article_id = ?", (article_id,))
|
|
95
107
|
conn.execute("DELETE FROM reading_sessions WHERE article_id = ?", (article_id,))
|
|
108
|
+
# ⚠️ 遇见记录也必须一起清。
|
|
109
|
+
# 重遇次数按 COUNT(DISTINCT article_id) 计算,而统计口径包含 action='lookup' ——
|
|
110
|
+
# 文章删了、记录还在,就成了「幽灵文章」:明明只剩 5 篇,界面却显示跨 6 篇。
|
|
111
|
+
# 实测就这么虚高过(journal 多算 1 篇)。
|
|
112
|
+
conn.execute("DELETE FROM word_encounters WHERE article_id = ?", (article_id,))
|
|
96
113
|
conn.execute("DELETE FROM articles WHERE id = ?", (article_id,))
|
|
97
|
-
return {"status": "ok", "message": "
|
|
114
|
+
return {"status": "ok", "message": "文章已删除(含它产生的遇见记录)"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""书架 API:公共领域书籍的搜索、下载、导入材料。
|
|
2
|
+
|
|
3
|
+
只接 Project Gutenberg。这类书籍版权已过期,可合法下载与再加工,
|
|
4
|
+
是唯一没有版权风险的语料来源。
|
|
5
|
+
"""
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
|
|
8
|
+
from app.services import bookshelf as bs
|
|
9
|
+
from app.services import materials as mt
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/api/books", tags=["books"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/search")
|
|
15
|
+
def search(q: str = "", page: int = 1, language: str = "en"):
|
|
16
|
+
if not q.strip():
|
|
17
|
+
return {"results": [], "page": page, "has_next": False}
|
|
18
|
+
try:
|
|
19
|
+
return bs.search_books(q, page=page, language=language)
|
|
20
|
+
except Exception as e:
|
|
21
|
+
raise HTTPException(status_code=502, detail=f"书源访问失败:{e}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("")
|
|
25
|
+
def list_all(status: str | None = None):
|
|
26
|
+
return bs.list_books(status=status)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.post("/download")
|
|
30
|
+
def download(payload: dict):
|
|
31
|
+
source_id = str(payload.get("source_id") or "").strip()
|
|
32
|
+
if not source_id:
|
|
33
|
+
raise HTTPException(status_code=400, detail="缺少 source_id")
|
|
34
|
+
result = bs.download_book(
|
|
35
|
+
source_id,
|
|
36
|
+
title=payload.get("title", ""),
|
|
37
|
+
author=payload.get("author", ""),
|
|
38
|
+
language=payload.get("language", "en"),
|
|
39
|
+
subjects=payload.get("subjects") or [],
|
|
40
|
+
)
|
|
41
|
+
if not result.get("ok"):
|
|
42
|
+
raise HTTPException(status_code=502, detail=result.get("error", "下载失败"))
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.post("/{book_id}/import")
|
|
47
|
+
def import_as_material(book_id: int):
|
|
48
|
+
"""把书架里的书导入为材料,供蒸馏与出题使用。"""
|
|
49
|
+
result = mt.import_book(book_id)
|
|
50
|
+
if not result.get("ok"):
|
|
51
|
+
raise HTTPException(status_code=400, detail=result.get("error", "导入失败"))
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.delete("/{book_id}")
|
|
56
|
+
def remove(book_id: int):
|
|
57
|
+
if not bs.delete_book(book_id):
|
|
58
|
+
raise HTTPException(status_code=404, detail="书籍不存在")
|
|
59
|
+
return {"ok": True}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""知识图谱 API。
|
|
2
|
+
|
|
3
|
+
同一份图两套用途:前端力导向可视化(给用户看)+ 邻接检索(给 AI 用)。
|
|
4
|
+
"""
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
|
|
7
|
+
from app.services import graph as g
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/graph", tags=["graph"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("")
|
|
13
|
+
def get_graph(types: str = "", limit: int = 400):
|
|
14
|
+
"""取图数据。types 用逗号分隔,例如 word,phrase。"""
|
|
15
|
+
node_types = [t.strip() for t in types.split(",") if t.strip()] or None
|
|
16
|
+
return g.get_graph(node_types=node_types, limit=max(20, min(limit, 1200)))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/stats")
|
|
20
|
+
def stats():
|
|
21
|
+
return g.stats()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/node/{node_id}")
|
|
25
|
+
def neighbors(node_id: int, depth: int = 1):
|
|
26
|
+
return g.neighbors(node_id, depth=max(1, min(depth, 3)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.post("/rebuild")
|
|
30
|
+
def rebuild(payload: dict | None = None):
|
|
31
|
+
payload = payload or {}
|
|
32
|
+
try:
|
|
33
|
+
return g.rebuild(
|
|
34
|
+
include_words=bool(payload.get("words", True)),
|
|
35
|
+
include_phrases=bool(payload.get("phrases", True)),
|
|
36
|
+
include_docs=bool(payload.get("docs", True)),
|
|
37
|
+
max_nodes=int(payload.get("max_nodes", 600)),
|
|
38
|
+
)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise HTTPException(status_code=500, detail=f"重建失败:{e}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.delete("")
|
|
44
|
+
def clear():
|
|
45
|
+
g.clear()
|
|
46
|
+
return {"ok": True}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""材料与蒸馏 API。
|
|
2
|
+
|
|
3
|
+
材料 = 用户自己的文本(粘贴 / 文件 / 书架导入)。
|
|
4
|
+
蒸馏 = 从材料里提取统计特征,产出「风格画像」,供文章生成参考。
|
|
5
|
+
画像里只有统计量,不含原文。
|
|
6
|
+
"""
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
|
|
9
|
+
from app.services import distill as ds
|
|
10
|
+
from app.services import materials as mt
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/api/materials", tags=["materials"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("")
|
|
16
|
+
def list_all():
|
|
17
|
+
return mt.list_materials()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.post("/paste")
|
|
21
|
+
def paste(payload: dict):
|
|
22
|
+
result = mt.import_text(payload.get("title", ""), payload.get("content", ""))
|
|
23
|
+
if not result.get("ok"):
|
|
24
|
+
raise HTTPException(status_code=400, detail=result.get("error", "导入失败"))
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.post("/import")
|
|
29
|
+
def import_path(payload: dict):
|
|
30
|
+
path = payload.get("path", "")
|
|
31
|
+
if not path:
|
|
32
|
+
raise HTTPException(status_code=400, detail="缺少文件路径")
|
|
33
|
+
result = mt.import_file(path, title=payload.get("title"))
|
|
34
|
+
if not result.get("ok"):
|
|
35
|
+
# 解析失败也返回 200 + ok:false,前端要展示具体原因(例如缺 PDF 依赖)
|
|
36
|
+
return result
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/{material_id}")
|
|
41
|
+
def detail(material_id: int, with_content: bool = False):
|
|
42
|
+
m = mt.get_material(material_id, with_content=with_content)
|
|
43
|
+
if not m:
|
|
44
|
+
raise HTTPException(status_code=404, detail="材料不存在")
|
|
45
|
+
return m
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.delete("/{material_id}")
|
|
49
|
+
def remove(material_id: int):
|
|
50
|
+
if not mt.delete_material(material_id):
|
|
51
|
+
raise HTTPException(status_code=404, detail="材料不存在")
|
|
52
|
+
return {"ok": True}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------- 蒸馏
|
|
56
|
+
|
|
57
|
+
@router.post("/distill")
|
|
58
|
+
def distill(payload: dict):
|
|
59
|
+
ids = payload.get("material_ids") or []
|
|
60
|
+
if not isinstance(ids, list) or not ids:
|
|
61
|
+
raise HTTPException(status_code=400, detail="请选择至少一份材料")
|
|
62
|
+
result = ds.distill_materials(
|
|
63
|
+
[int(i) for i in ids],
|
|
64
|
+
name=payload.get("name", ""),
|
|
65
|
+
activate=bool(payload.get("activate", False)),
|
|
66
|
+
)
|
|
67
|
+
if not result.get("ok"):
|
|
68
|
+
raise HTTPException(status_code=400, detail=result.get("error", "蒸馏失败"))
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.get("/profiles/all")
|
|
73
|
+
def profiles():
|
|
74
|
+
return ds.list_profiles()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.post("/profiles/{profile_id}/activate")
|
|
78
|
+
def activate(profile_id: int):
|
|
79
|
+
if not ds.activate_profile(profile_id):
|
|
80
|
+
raise HTTPException(status_code=404, detail="画像不存在")
|
|
81
|
+
return {"ok": True}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.delete("/profiles/{profile_id}")
|
|
85
|
+
def remove_profile(profile_id: int):
|
|
86
|
+
if not ds.delete_profile(profile_id):
|
|
87
|
+
raise HTTPException(status_code=404, detail="画像不存在")
|
|
88
|
+
return {"ok": True}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""词汇量定级测试 API。
|
|
2
|
+
|
|
3
|
+
无状态设计:GET 出题 → 客户端本地作答 → POST 一次性提交评分。
|
|
4
|
+
服务器不保存进行中的试卷,省掉一整类「会话过期 / 并发覆盖」问题。
|
|
5
|
+
"""
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
|
|
8
|
+
from app.services import placement as pl
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/api/placement", tags=["placement"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/items")
|
|
14
|
+
def items(per_band: int = pl.ITEMS_PER_BAND, pseudo: int = pl.PSEUDO_COUNT):
|
|
15
|
+
"""生成一份测试卷。"""
|
|
16
|
+
return pl.build_items(
|
|
17
|
+
items_per_band=max(3, min(per_band, 60)),
|
|
18
|
+
pseudo_count=max(0, min(pseudo, len(pl.PSEUDO_WORDS))),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/submit")
|
|
23
|
+
def submit(payload: dict):
|
|
24
|
+
"""提交作答,评分并落库。payload: {"answers": [{"text": "...", "known": true}]}"""
|
|
25
|
+
answers = payload.get("answers")
|
|
26
|
+
if not isinstance(answers, list) or not answers:
|
|
27
|
+
raise HTTPException(status_code=400, detail="没有作答内容")
|
|
28
|
+
result = pl.score(answers)
|
|
29
|
+
if not result["bands"]:
|
|
30
|
+
raise HTTPException(status_code=400, detail="作答内容无法匹配到题库,请重新测试")
|
|
31
|
+
result["run_id"] = pl.save_run(result, answers)
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("/summary")
|
|
36
|
+
def summary():
|
|
37
|
+
"""最近一次测试结果(没测过返回 has_result: false)。"""
|
|
38
|
+
return pl.summary()
|
|
@@ -45,6 +45,9 @@ async def record_lookup(word_id: int = 0, article_id: int = 0, context: str = ""
|
|
|
45
45
|
"VALUES (?, ?, ?, 'lookup', ?)",
|
|
46
46
|
(word_id, article_id or None, context, now),
|
|
47
47
|
)
|
|
48
|
+
# 查词是掌握度模型里权重很高的负向证据,立刻重算该词
|
|
49
|
+
from app.services import mastery
|
|
50
|
+
mastery.refresh([word_id])
|
|
48
51
|
# 获取当前 lookup_count(更新前)
|
|
49
52
|
row = conn.execute("SELECT lookup_count, status FROM words WHERE id=?", (word_id,)).fetchone()
|
|
50
53
|
if row:
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import time
|
|
3
3
|
|
|
4
4
|
import httpx
|
|
5
|
-
from fastapi import APIRouter
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
6
|
from pydantic import BaseModel
|
|
7
7
|
|
|
8
8
|
from app.database import get_db
|
|
9
|
+
from app.services import encounter, mastery, selection, vocab_gap
|
|
9
10
|
|
|
10
11
|
router = APIRouter(prefix="/api", tags=["stats"])
|
|
11
12
|
|
|
@@ -96,6 +97,7 @@ async def stats_overview():
|
|
|
96
97
|
"vocab_estimate": vocab_estimate,
|
|
97
98
|
"test_accuracy": round(test_correct / test_total * 100, 1) if test_total > 0 else 0,
|
|
98
99
|
"srs": srs,
|
|
100
|
+
"reentry": encounter.summary(),
|
|
99
101
|
"hot_words": [{"text": r["text"], "count": r["lookup_count"]} for r in hot_words],
|
|
100
102
|
}
|
|
101
103
|
|
|
@@ -252,3 +254,55 @@ async def test_api_connection(req: TestApiRequest):
|
|
|
252
254
|
return {"ok": False, "error": f"HTTP {resp2.status_code}: {resp2.text[:100]}"}
|
|
253
255
|
except Exception as e:
|
|
254
256
|
return {"ok": False, "error": str(e)[:100]}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@router.get("/stats/reentry")
|
|
260
|
+
def reentry(limit: int = 200, only_active: bool = True):
|
|
261
|
+
"""重遇进度:每个学习词跨了多少篇文章。
|
|
262
|
+
|
|
263
|
+
口径是「跨多少篇」而不是「见了几次」—— 方法要求的是**变化的语境**,
|
|
264
|
+
同一篇文章里看 12 遍没有意义。
|
|
265
|
+
"""
|
|
266
|
+
return {
|
|
267
|
+
"summary": encounter.summary(),
|
|
268
|
+
"items": encounter.reentry_stats(limit=max(1, min(limit, 1000)),
|
|
269
|
+
only_active=only_active),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@router.get("/stats/vocab-gap")
|
|
274
|
+
def vocab_gap_overview():
|
|
275
|
+
"""词汇差距:词汇量、真题覆盖率、各考试缺口、进度预测。
|
|
276
|
+
|
|
277
|
+
覆盖率不是「认识多少词表里的词」,而是**按词频档位给语料加权** ——
|
|
278
|
+
实测 CET4 大纲词表只覆盖四级真题语料 15.8% 的 token(词表装的是内容词,
|
|
279
|
+
剩下 84% 是基础词与功能词),拿词表百分比当「能读懂多少」会严重失真。
|
|
280
|
+
"""
|
|
281
|
+
# 没有定级结果时铺一条默认假设,否则新用户的整页数字都是空的
|
|
282
|
+
from app.services import placement as pl
|
|
283
|
+
pl.ensure_default_run()
|
|
284
|
+
return vocab_gap.overview()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@router.get("/stats/target-exam")
|
|
288
|
+
def get_target_exam():
|
|
289
|
+
"""当前目标考试。"""
|
|
290
|
+
return {"exam": selection.target_exam(),
|
|
291
|
+
"label": selection.EXAM_LABELS.get(selection.target_exam(), ""),
|
|
292
|
+
"options": [{"key": k, "label": v} for k, v in selection.EXAM_LABELS.items()]}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@router.post("/stats/target-exam")
|
|
296
|
+
def set_target_exam(payload: dict):
|
|
297
|
+
"""设置目标考试 —— 它决定下一篇埋哪些词。"""
|
|
298
|
+
exam = str(payload.get("exam", "")).strip()
|
|
299
|
+
if not selection.set_target_exam(exam):
|
|
300
|
+
raise HTTPException(status_code=400, detail=f"不支持的考试:{exam}")
|
|
301
|
+
return {"ok": True, "exam": exam,
|
|
302
|
+
"label": selection.EXAM_LABELS.get(exam, "")}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@router.get("/stats/mastery")
|
|
306
|
+
def mastery_stats():
|
|
307
|
+
"""掌握度分布:识别 vs 回忆分开统计。"""
|
|
308
|
+
return mastery.stats()
|