You've already forked HeurAMS-legacy
fix
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,7 +16,8 @@ old/
|
||||
# config/
|
||||
data/cache/
|
||||
data/electron/
|
||||
#data/nucleon/
|
||||
data/nucleon/
|
||||
!data/nucleon/test*
|
||||
data/orbital/
|
||||
AGENTS.md
|
||||
|
||||
|
||||
67
README.md
67
README.md
@@ -7,14 +7,14 @@
|
||||
"潜进" 软件组项目包含多个子项目
|
||||
此仓库包含了 "潜进" 项目的核心和基于 Textual 的基本用户界面的实现
|
||||
|
||||
## 开发计划
|
||||
0.0.x: 简易调度器实现与最小原型.
|
||||
0.1.x: 命令行操作的调度器.
|
||||
0.2.x: 使用 Textual 构建富文本终端用户界面, 项目可行性验证, 采用 SM-2 原始算法, 评估方式为用户自评估的原型.
|
||||
0.3.x: 简单的多文件项目, 创建了记忆内容/算法数据结构, 基于 SM-2 改进算法的自动复习测评评估. 重点设计古诗文记忆理解功能, 以及 TUI 界面实现, 简单的 TTS 集成.
|
||||
0.4.x (当前): 使用模块管理解耦设计, 增加文档与类型标注, 采用上下文设计模式的隐式依赖注入与遵从 IoC, 注册器设计的算法与功能实现, 支持其他调度算法模块 (SM-2, FSRS) 与谜题模块, 采用日志调试, 更新文件格式, 引入动态数据模式(宏驱动的动态内容生成), 与基于文件的策略调控, 更佳的用户数据处理, 加入模块化扩展集成, 将算法数据格式换为 json 提高性能, 采用 provider-service 抽象架构, 支持切换服务提供者, 整体兼容性改进.
|
||||
下一步?
|
||||
使用 Flutter 构建酷酷的现代化前端, 增加云同步/文档源服务...
|
||||
## 开发进程
|
||||
- 0.0.x: 简易调度器实现与最小原型.
|
||||
- 0.1.x: 命令行操作的调度器.
|
||||
- 0.2.x: 使用 Textual 构建富文本终端用户界面, 项目可行性验证, 采用 SM-2 原始算法, 评估方式为用户自评估的原型.
|
||||
- 0.3.x: 简单的多文件项目, 创建了记忆内容/算法数据结构, 基于 SM-2 改进算法的自动复习测评评估. 重点设计古诗文记忆理解功能, 以及 TUI 界面实现, 简单的 TTS 集成.
|
||||
- 0.4.x: 使用模块管理解耦设计, 增加文档与类型标注, 采用上下文设计模式的隐式依赖注入与遵从 IoC, 注册器设计的算法与功能实现, 支持其他调度算法模块 (SM-2, FSRS) 与谜题模块, 采用日志调试, 更新文件格式, 引入动态数据模式(宏驱动的动态内容生成), 与基于文件的策略调控, 更佳的用户数据处理, 加入模块化扩展集成, 将算法数据格式换为 json 提高性能, 采用 provider-service 抽象架构, 支持切换服务提供者, 整体兼容性改进.
|
||||
> 下一步?
|
||||
> 使用 Flutter 构建酷酷的现代化前端, 增加云同步/文档源服务...
|
||||
|
||||
## 特性
|
||||
|
||||
@@ -84,55 +84,6 @@ python -m heurams.interface
|
||||
|
||||
配置文件位于 `config/config.toml`(相对于工作目录)。如果不存在,会使用内置的默认配置。
|
||||
|
||||
### 创建配置文件
|
||||
复制示例配置:
|
||||
```bash
|
||||
cp -r config.example/config.toml config/
|
||||
```
|
||||
|
||||
然后编辑 `config/config.toml` 以符合你的需求。
|
||||
|
||||
### 主要配置项
|
||||
```toml
|
||||
# 调试设置
|
||||
persist_to_file = 1 # 是否将更改保存到文件
|
||||
daystamp_override = -1 # 覆盖日戳(测试用)
|
||||
timestamp_override = -1 # 覆盖时间戳(测试用)
|
||||
quick_pass = 0 # 一键通过模式
|
||||
|
||||
# 每个会话默认新记忆单元数量
|
||||
tasked_number = 8
|
||||
|
||||
# 时区偏移(秒),用于日戳计算
|
||||
timezone_offset = +28800 # 中国标准时间 (UTC+8)
|
||||
|
||||
# 谜题默认配置
|
||||
[puzzles.mcq]
|
||||
max_riddles_num = 2 # 选择题最大谜题数
|
||||
|
||||
[puzzles.cloze]
|
||||
min_denominator = 3 # 填空题最小分母
|
||||
|
||||
# 路径配置(相对于工作目录或绝对路径)
|
||||
[paths]
|
||||
nucleon_dir = "./data/nucleon"
|
||||
electron_dir = "./data/electron"
|
||||
orbital_dir = "./data/orbital"
|
||||
cache_dir = "./data/cache"
|
||||
template_dir = "./data/template"
|
||||
|
||||
# 服务提供者配置
|
||||
[services]
|
||||
audio = "playsound" # 可选: playsound, termux
|
||||
tts = "edgetts" # 可选: edgetts
|
||||
llm = "openai" # 可选: openai
|
||||
|
||||
# OpenAI 兼容 LLM 配置
|
||||
[providers.llm.openai]
|
||||
url = "" # API 端点 URL
|
||||
key = "" # API 密钥
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
### 架构图
|
||||
@@ -183,6 +134,7 @@ graph TB
|
||||
TUI --> AudioService
|
||||
TUI --> TTSService
|
||||
TUI --> OtherServices
|
||||
Config --> Files
|
||||
Config --> Context
|
||||
AudioService --> AudioProvider
|
||||
TTSService --> TTSProvider
|
||||
@@ -192,7 +144,6 @@ graph TB
|
||||
Reactor --> Puzzles
|
||||
Particles --> Files
|
||||
Algorithms --> Files
|
||||
Config --> Files
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
|
||||
@@ -6,10 +6,10 @@ daystamp_override = -1
|
||||
timestamp_override = -1
|
||||
|
||||
# [调试] 一键通过
|
||||
quick_pass = 0
|
||||
quick_pass = 1
|
||||
|
||||
# 对于每个项目的默认新记忆原子数量
|
||||
tasked_number = 8
|
||||
scheduled_num = 8
|
||||
|
||||
# UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒
|
||||
timezone_offset = +28800 # 中国标准时间 (UTC+8)
|
||||
|
||||
@@ -6,6 +6,22 @@ build-backend = "setuptools.build_meta"
|
||||
name = "heurams"
|
||||
version = "0.4.0"
|
||||
description = "Heuristic Assisted Memory Scheduler"
|
||||
license = {file = "LICENSE"}
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Education",
|
||||
"Intended Audience :: Education",
|
||||
]
|
||||
keywords = ["spaced-repetition", "memory", "learning", "tui", "textual", "flashcards", "education"]
|
||||
dependencies = [
|
||||
"bidict==0.23.1",
|
||||
"playsound==1.2.2",
|
||||
"textual==5.3.0",
|
||||
"toml==0.10.2",
|
||||
]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
where = ["src"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
bidict==0.23.1
|
||||
playsound==1.2.2
|
||||
textual==6.9.0
|
||||
textual==5.3.0
|
||||
toml==0.10.2
|
||||
|
||||
@@ -79,4 +79,8 @@ if is_subdir(Path(rootdir), Path(os.getcwd())):
|
||||
environment_check()
|
||||
|
||||
app = HeurAMSApp()
|
||||
app.run()
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
||||
def main():
|
||||
app.run()
|
||||
|
||||
@@ -88,9 +88,6 @@ class AboutScreen(Screen):
|
||||
def action_go_back(self):
|
||||
self.app.pop_screen()
|
||||
|
||||
def action_quit_app(self):
|
||||
self.app.exit()
|
||||
|
||||
def on_button_pressed(self, event) -> None:
|
||||
event.stop()
|
||||
if event.button.id == "back_button":
|
||||
|
||||
@@ -113,7 +113,7 @@ class DashboardScreen(Screen):
|
||||
return
|
||||
|
||||
selected_label = event.item.query_one(Label)
|
||||
if "未找到任何 .toml 文件" in str(selected_label.renderable):
|
||||
if "未找到任何 .toml 文件" in str(selected_label.renderable): # type: ignore
|
||||
return
|
||||
|
||||
selected_filename = pathlib.Path(
|
||||
|
||||
@@ -25,7 +25,7 @@ logger = get_logger(__name__)
|
||||
class MemScreen(Screen):
|
||||
BINDINGS = [
|
||||
("q", "pop_screen", "返回"),
|
||||
("p", "prev", "复习上一个"),
|
||||
# ("p", "prev", "复习上一个"),
|
||||
("d", "toggle_dark", ""),
|
||||
("v", "play_voice", "朗读"),
|
||||
("0,1,2,3", "app.push_screen('about')", ""),
|
||||
@@ -91,6 +91,7 @@ class MemScreen(Screen):
|
||||
progress_widget.update(self._get_progress_text()) # type: ignore
|
||||
|
||||
def load_puzzle(self):
|
||||
self.atom: pt.Atom = self.procession.current_atom
|
||||
container = self.query_one("#puzzle-container")
|
||||
for i in container.children:
|
||||
i.remove()
|
||||
@@ -108,6 +109,8 @@ class MemScreen(Screen):
|
||||
event.stop()
|
||||
|
||||
def watch_rating(self, old_rating, new_rating) -> None:
|
||||
if self.procession == 0:
|
||||
return
|
||||
if new_rating == -1:
|
||||
return
|
||||
forwards = 1 if new_rating >= 4 else 0
|
||||
@@ -131,6 +134,9 @@ class MemScreen(Screen):
|
||||
self.procession.append()
|
||||
self.update_display()
|
||||
|
||||
def action_quick_pass(self):
|
||||
self.rating = 5
|
||||
|
||||
def action_play_voice(self):
|
||||
"""朗读当前内容"""
|
||||
pass
|
||||
|
||||
@@ -20,7 +20,7 @@ from heurams.context import config_var
|
||||
|
||||
class NucleonCreatorScreen(Screen):
|
||||
BINDINGS = [("q", "go_back", "返回")]
|
||||
|
||||
SUB_TITLE = "单元集创建向导"
|
||||
def __init__(self) -> None:
|
||||
super().__init__(name=None, id=None, classes=None)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class PrecachingScreen(Screen):
|
||||
nucleons (list): 可选列表, 仅包含 Nucleon 对象
|
||||
desc (list): 可选字符串, 包含对此次调用的文字描述
|
||||
"""
|
||||
|
||||
SUB_TITLE = "缓存管理器"
|
||||
BINDINGS = [("q", "go_back", "返回")]
|
||||
|
||||
def __init__(self, nucleons: list = [], desc: str = ""):
|
||||
|
||||
@@ -10,10 +10,15 @@ from textual.widgets import (
|
||||
)
|
||||
from textual.containers import ScrollableContainer
|
||||
from textual.screen import Screen
|
||||
|
||||
from heurams.context import config_var
|
||||
import heurams.kernel.particles as pt
|
||||
import heurams.services.hasher as hasher
|
||||
from heurams.context import *
|
||||
from textual.reactive import reactive
|
||||
from textual.widget import Widget
|
||||
from heurams.services.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PreparationScreen(Screen):
|
||||
@@ -27,6 +32,8 @@ class PreparationScreen(Screen):
|
||||
("0,1,2,3", "app.push_screen('about')", ""),
|
||||
]
|
||||
|
||||
scheduled_num = reactive(config_var.get()['scheduled_num'])
|
||||
|
||||
def __init__(self, nucleon_file: pathlib.Path, electron_file: pathlib.Path) -> None:
|
||||
super().__init__(name=None, id=None, classes=None)
|
||||
self.nucleon_file = nucleon_file
|
||||
@@ -38,9 +45,10 @@ class PreparationScreen(Screen):
|
||||
yield Header(show_clock=True)
|
||||
with ScrollableContainer(id="vice_container"):
|
||||
yield Label(f"准备就绪: [b]{self.nucleon_file.stem}[/b]\n")
|
||||
yield Label(f"内容源文件对象: ./nucleon/[b]{self.nucleon_file.name}[/b]")
|
||||
yield Label(f"元数据文件对象: ./electron/[b]{self.electron_file.name}[/b]")
|
||||
yield Label(f"内容源文件: {config_var.get()['paths']['nucleon_dir']}/[b]{self.nucleon_file.name}[/b]")
|
||||
yield Label(f"元数据文件: {config_var.get()['paths']['electron_dir']}/[b]{self.electron_file.name}[/b]")
|
||||
yield Label(f"\n单元数量: {len(self.nucleons_with_orbital)}\n")
|
||||
yield Label(f"单次记忆数量: {self.scheduled_num}", id="schnum_label")
|
||||
|
||||
yield Button(
|
||||
"开始记忆",
|
||||
@@ -59,6 +67,14 @@ class PreparationScreen(Screen):
|
||||
yield Markdown(self._get_full_content().replace("/", ""), classes="full")
|
||||
yield Footer()
|
||||
|
||||
#def watch_scheduled_num(self, old_scheduled_num, new_scheduled_num):
|
||||
# logger.debug("响应", old_scheduled_num, "->", new_scheduled_num)
|
||||
# try:
|
||||
# one = self.query_one("#schnum_label")
|
||||
# one.update(f"单次记忆数量: {new_scheduled_num}") # type: ignore
|
||||
# except:
|
||||
# pass
|
||||
|
||||
def _get_full_content(self):
|
||||
content = ""
|
||||
for nucleon, orbital in self.nucleons_with_orbital:
|
||||
@@ -84,6 +100,7 @@ class PreparationScreen(Screen):
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
event.stop()
|
||||
logger.debug("按下按钮")
|
||||
if event.button.id == "start_memorizing_button":
|
||||
atoms = list()
|
||||
for nucleon, orbital in self.nucleons_with_orbital:
|
||||
@@ -101,9 +118,22 @@ class PreparationScreen(Screen):
|
||||
atom.link("electron_path", self.electron_file)
|
||||
atom.link("orbital_path", None)
|
||||
atoms.append(atom)
|
||||
atoms_to_provide = list()
|
||||
left_new = self.scheduled_num
|
||||
for i in atoms:
|
||||
i: pt.Atom
|
||||
if i.registry["electron"].is_due():
|
||||
atoms_to_provide.append(i)
|
||||
else:
|
||||
if i.registry["electron"].is_activated():
|
||||
pass
|
||||
else:
|
||||
left_new -= 1
|
||||
if left_new >= 0:
|
||||
atoms_to_provide.append(i)
|
||||
logger.debug(f"ATP: {atoms_to_provide}")
|
||||
from .memorizor import MemScreen
|
||||
|
||||
memscreen = MemScreen(atoms)
|
||||
memscreen = MemScreen(atoms_to_provide)
|
||||
self.app.push_screen(memscreen)
|
||||
elif event.button.id == "precache_button":
|
||||
self.action_precache()
|
||||
|
||||
@@ -105,8 +105,11 @@ class Recognition(BasePuzzleWidget):
|
||||
self.handler(5)
|
||||
|
||||
def handler(self, rating):
|
||||
if not self.atom.registry["electron"].is_activated() and not self.atom.registry["runtime"]["locked"]:
|
||||
self.atom.registry["electron"].activate()
|
||||
logger.debug(f"激活原子 {self.atom}")
|
||||
self.atom.lock(1)
|
||||
self.atom.minimize(5)
|
||||
if not self.atom.registry["runtime"]["locked"]:
|
||||
if not self.atom.registry["electron"].is_activated():
|
||||
self.atom.registry["electron"].activate()
|
||||
logger.debug(f"激活原子 {self.atom}")
|
||||
self.atom.lock(1)
|
||||
self.atom.minimize(5)
|
||||
else:
|
||||
pass
|
||||
@@ -67,7 +67,7 @@ class Electron:
|
||||
logger.debug("Electron.is_due: 检查 ident='%s'", self.ident)
|
||||
result = self.algo.is_due(self.algodata)
|
||||
logger.debug("is_due 结果: %s", result)
|
||||
return result
|
||||
return (result and self.is_activated())
|
||||
|
||||
def is_activated(self):
|
||||
result = self.algodata[self.algo.algo_name]["is_activated"]
|
||||
|
||||
145
tests/interface/test_dashboard.py
Normal file
145
tests/interface/test_dashboard.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DashboardScreen 的测试,包括单元测试和 pilot 测试。
|
||||
"""
|
||||
import unittest
|
||||
import tempfile
|
||||
import pathlib
|
||||
import time
|
||||
from unittest.mock import patch, MagicMock
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from heurams.context import ConfigContext
|
||||
from heurams.services.config import ConfigFile
|
||||
from heurams.interface.__main__ import HeurAMSApp
|
||||
from heurams.interface.screens.dashboard import DashboardScreen
|
||||
|
||||
|
||||
class TestDashboardScreenUnit(unittest.TestCase):
|
||||
"""DashboardScreen 的单元测试(不启动完整应用)。"""
|
||||
|
||||
def setUp(self):
|
||||
"""在每个测试之前运行,设置临时目录和配置。"""
|
||||
# 创建临时目录用于测试数据
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.temp_path = pathlib.Path(self.temp_dir.name)
|
||||
|
||||
# 创建默认配置,并修改路径指向临时目录
|
||||
default_config_path = (
|
||||
pathlib.Path(__file__).parent.parent.parent
|
||||
/ "src/heurams/default/config/config.toml"
|
||||
)
|
||||
self.config = ConfigFile(default_config_path)
|
||||
# 更新配置中的路径
|
||||
config_data = self.config.data
|
||||
config_data["paths"]["nucleon_dir"] = str(self.temp_path / "nucleon")
|
||||
config_data["paths"]["electron_dir"] = str(self.temp_path / "electron")
|
||||
config_data["paths"]["orbital_dir"] = str(self.temp_path / "orbital")
|
||||
config_data["paths"]["cache_dir"] = str(self.temp_path / "cache")
|
||||
# 禁用快速通过,避免测试干扰
|
||||
config_data["quick_pass"] = 0
|
||||
# 禁用时间覆盖
|
||||
config_data["daystamp_override"] = -1
|
||||
config_data["timestamp_override"] = -1
|
||||
|
||||
# 创建目录
|
||||
for dir_key in ["nucleon_dir", "electron_dir", "orbital_dir", "cache_dir"]:
|
||||
pathlib.Path(config_data["paths"][dir_key]).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 使用 ConfigContext 设置配置
|
||||
self.config_ctx = ConfigContext(self.config)
|
||||
self.config_ctx.__enter__()
|
||||
|
||||
def tearDown(self):
|
||||
"""在每个测试之后清理。"""
|
||||
self.config_ctx.__exit__(None, None, None)
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_compose(self):
|
||||
"""测试 compose 方法返回正确的部件。"""
|
||||
screen = DashboardScreen()
|
||||
# 手动调用 compose 并收集部件
|
||||
from textual.app import ComposeResult
|
||||
result = screen.compose()
|
||||
widgets = list(result)
|
||||
# 检查是否包含 Header 和 Footer
|
||||
from textual.widgets import Header, Footer
|
||||
header_present = any(isinstance(w, Header) for w in widgets)
|
||||
footer_present = any(isinstance(w, Footer) for w in widgets)
|
||||
self.assertTrue(header_present)
|
||||
self.assertTrue(footer_present)
|
||||
# 检查是否有 ScrollableContainer
|
||||
from textual.containers import ScrollableContainer
|
||||
container_present = any(isinstance(w, ScrollableContainer) for w in widgets)
|
||||
self.assertTrue(container_present)
|
||||
# 使用 query_one 查找 union-list,即使屏幕未挂载也可能有效
|
||||
list_view = screen.query_one("#union-list")
|
||||
self.assertIsNotNone(list_view)
|
||||
self.assertEqual(list_view.id, "union-list")
|
||||
self.assertEqual(list_view.__class__.__name__, "ListView")
|
||||
|
||||
def test_item_desc_generator(self):
|
||||
"""测试 item_desc_generator 函数。"""
|
||||
screen = DashboardScreen()
|
||||
# 模拟一个文件名
|
||||
filename = "test.toml"
|
||||
result = screen.item_desc_generator(filename)
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertIn(0, result)
|
||||
self.assertIn(1, result)
|
||||
# 检查内容
|
||||
self.assertIn("test.toml", result[0])
|
||||
# 由于 electron 文件不存在,应显示“尚未激活”
|
||||
self.assertIn("尚未激活", result[1])
|
||||
|
||||
|
||||
@unittest.skip("Pilot 测试需要进一步配置,暂不运行")
|
||||
class TestDashboardScreenPilot(unittest.TestCase):
|
||||
"""使用 Textual Pilot 的集成测试。"""
|
||||
|
||||
def setUp(self):
|
||||
"""配置临时目录和配置。"""
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.temp_path = pathlib.Path(self.temp_dir.name)
|
||||
|
||||
default_config_path = (
|
||||
pathlib.Path(__file__).parent.parent.parent
|
||||
/ "src/heurams/default/config/config.toml"
|
||||
)
|
||||
self.config = ConfigFile(default_config_path)
|
||||
config_data = self.config.data
|
||||
config_data["paths"]["nucleon_dir"] = str(self.temp_path / "nucleon")
|
||||
config_data["paths"]["electron_dir"] = str(self.temp_path / "electron")
|
||||
config_data["paths"]["orbital_dir"] = str(self.temp_path / "orbital")
|
||||
config_data["paths"]["cache_dir"] = str(self.temp_path / "cache")
|
||||
config_data["quick_pass"] = 0
|
||||
config_data["daystamp_override"] = -1
|
||||
config_data["timestamp_override"] = -1
|
||||
|
||||
for dir_key in ["nucleon_dir", "electron_dir", "orbital_dir", "cache_dir"]:
|
||||
pathlib.Path(config_data["paths"][dir_key]).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.config_ctx = ConfigContext(self.config)
|
||||
self.config_ctx.__enter__()
|
||||
|
||||
def tearDown(self):
|
||||
self.config_ctx.__exit__(None, None, None)
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_dashboard_loads_with_pilot(self):
|
||||
"""使用 Pilot 测试 DashboardScreen 加载。"""
|
||||
with patch('heurams.interface.__main__.environment_check'):
|
||||
app = HeurAMSApp()
|
||||
# 注意:Pilot 在 Textual 6.9.0 中的用法可能不同
|
||||
# 以下为示例代码,可能需要调整
|
||||
pilot = Pilot(app)
|
||||
# 等待应用启动
|
||||
pilot.pause()
|
||||
screen = app.screen
|
||||
self.assertEqual(screen.__class__.__name__, "DashboardScreen")
|
||||
union_list = app.query_one("#union-list")
|
||||
self.assertIsNotNone(union_list)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user