Archived
0
0
This commit is contained in:
2025-12-17 20:43:32 +08:00
parent 1e534e5fe5
commit 321f287263
15 changed files with 235 additions and 82 deletions

3
.gitignore vendored
View File

@@ -16,7 +16,8 @@ old/
# config/
data/cache/
data/electron/
#data/nucleon/
data/nucleon/
!data/nucleon/test*
data/orbital/
AGENTS.md

View File

@@ -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
```
### 目录结构

View File

@@ -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)

View File

@@ -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"]

View File

@@ -1,4 +1,4 @@
bidict==0.23.1
playsound==1.2.2
textual==6.9.0
textual==5.3.0
toml==0.10.2

View File

@@ -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()

View File

@@ -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":

View File

@@ -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(

View File

@@ -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

View File

@@ -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)

View File

@@ -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 = ""):

View File

@@ -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()

View File

@@ -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

View File

@@ -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"]

View 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()