122 Commits

Author SHA1 Message Date
pluv 0e42d0410c fix: 改进与日志简化 2026-05-22 22:33:57 +08:00
pluv 31996f2532 feat: 网页界面与 API 总体布局 2026-05-20 23:58:33 +08:00
pluv 2415c1afdb chore: 修改目录结构 2026-05-20 23:57:03 +08:00
pluv f88475499f docs: 修改文档 2026-05-20 23:49:30 +08:00
pluv b31c045aa5 docs: 修改文档 2026-05-19 00:15:08 +08:00
pluv 3d113f2eaa feat: 改进命令行入口 2026-05-17 00:22:01 +08:00
pluv b0625ef636 fix: 修复 playsound3 依赖问题 2026-05-17 00:07:12 +08:00
pluv dc8fa36a28 fix: 修复 zmq 依赖问题 2026-05-17 00:05:04 +08:00
pluv 2918662222 docs: 修改文档 2026-05-17 00:01:09 +08:00
pluv 60caee0f67 docs: 修改文档 2026-05-16 23:59:25 +08:00
pluv 4ba164e2ab fix: 修改一处显示重复 2026-05-16 23:49:38 +08:00
pluv 2735465629 feat: 简化并统一命令行入口
删除了原有 __main__.py 中冗长且烦人的说教, heurams 现作为所有功能的
统一入口, 并移除了单独的 heurams-tui 入口(现为 heurams tui)
2026-05-16 23:46:39 +08:00
pluv be9e79b576 docs: 更换包下载方式 2026-05-16 23:30:54 +08:00
pluv 470a7383bf docs: 修改文档 2026-05-16 23:24:33 +08:00
pluv ceaebf2c54 docs: 修改文档 2026-05-16 23:21:07 +08:00
pluv 54daa4128d build: 将构建系统换为 hatchling
由于 uv_build 在 android termux 端需编译大量 rust 代码, 极大减慢安装速度, 故使用 hatchling 作为纯 python 构建系统, 这并不影响使用 uv 管理项目
2026-05-16 23:13:14 +08:00
pluv d22966b34d fix: 修复 Termux 声音功能 2026-05-16 22:56:57 +08:00
pluv 0889bfa1c3 fix: 几处修复 2026-05-15 13:53:48 +08:00
pluv 92796451d1 docs: 改动文档 2026-05-10 15:59:09 +08:00
pluv 66870c4987 docs: 修复一处换行 2026-05-10 00:24:08 +08:00
pluv 477fa972eb docs: 改变仓库说明 2026-05-10 00:22:04 +08:00
pluv 5b52e4b3ee chore: 改变仓库说明 2026-05-08 19:20:45 +08:00
pluv 5058fb010f chore: 改变仓库分支配置和说明 2026-05-08 19:20:45 +08:00
pluv f1e87c6ff2 docs: 改进文档 2026-05-08 19:20:45 +08:00
pluv 048e74ad7f style: 格式化代码 2026-05-07 19:48:07 +08:00
pluv fcda88488b Merge branch 'refactor/v0.5.0' into dev
完成 0.5.0 版本重构工作
2026-05-07 18:45:12 +08:00
pluv 69f8bfb853 docs: 修改文档 2026-05-07 18:43:49 +08:00
pluv e019ffc957 docs: 修改文档 2026-05-07 18:38:03 +08:00
pluv dd2469b49a fix: 修复若干可用性问题 2026-05-07 18:17:39 +08:00
pluv 7bf78ecdff fix: 修复一个依赖问题 2026-05-07 18:02:59 +08:00
pluv cf2283446d docs: 合并前准备 2026-05-07 17:55:22 +08:00
pluv d33aa7055b fix: 总体体验改进与版本号变更 2026-05-07 17:50:30 +08:00
pluv 1975474c94 docs: 修改文档 2026-05-07 13:26:22 +08:00
pluv c2496c1bb5 docs: 更新文档 2026-05-06 21:01:51 +08:00
pluv 1caccc9114 docs: 更新文档 2026-05-06 09:46:50 +08:00
pluv cd23c2d773 docs: 更新文档 2026-05-06 09:35:58 +08:00
pluv 1cbd27279d docs: 更新文档 2026-05-05 11:53:37 +08:00
pluv 0cc19ca92f chore: 更新文档 2026-05-05 10:34:51 +08:00
pluv 03300f0b18 feat: 升级到 playsound3 以替代问题频出的 playsound 2026-05-04 14:22:45 +08:00
pluv ca86b2d8e9 feat: 使算法能被自动注册 2026-05-04 13:55:57 +08:00
pluv e2b9fb94f3 fix: 稳定性修复 2026-05-04 13:13:05 +08:00
pluv 40f4d6c6e3 docs: 更新文档 2026-05-02 02:38:33 +08:00
pluv 520c78dbc5 docs: 更新文档 2026-05-02 02:36:14 +08:00
pluv 5182572cc7 chore: 修改版本文本 2026-04-30 04:52:17 +08:00
pluv a1c1c93c57 fix(interface): 修复持久化问题 2026-04-30 04:45:05 +08:00
pluv 22b0607273 fix: 修改一处语法错误 2026-04-27 02:27:29 +08:00
pluv a95ac55f00 chore: 说明依赖 2026-04-27 02:26:32 +08:00
pluv 7b558f2438 chore: 更新依赖和文档 2026-04-27 02:04:52 +08:00
pluv dead6cb1a1 feat: 高精度时间戳支持与 SM-15M 改进 2026-04-25 02:14:55 +08:00
pluv fa2f8fa701 feat: 增加早期 FSRS 支持 2026-04-25 01:54:18 +08:00
pluv 7f2bcf45de test: 增加测试 2026-04-25 01:38:33 +08:00
pluv b235651db5 docs: 修改文档 2026-04-24 15:40:42 +08:00
pluv f0b63fdeb6 docs: 修改文档 2026-04-24 15:38:10 +08:00
pluv 5b7b4ba443 style: 代码格式化 2026-04-23 00:13:57 +08:00
pluv 1c05f42b61 docs: 更新文档 2026-04-22 23:39:20 +08:00
pluv f50c19ba82 feat(interface): 组件自动聚焦与键盘操作改进 2026-04-22 22:54:25 +08:00
pluv c2a1867c49 fix(interface): 修复 CSS_PATH 导致的样式覆写问题 2026-04-22 06:55:58 +08:00
pluv b9d88383f9 perf: 用 autoflake 删除无用依赖
没想到居然能意外地大幅提升启动速度
2026-04-22 06:44:59 +08:00
pluv 093034828b feat: 开发 unifront 前端会话模块 2026-04-21 16:52:04 +08:00
pluv fc70aa07f6 refactor: 开始翻新状态机 2026-04-21 12:52:30 +08:00
pluv e53e905e1b fix: 修复部分问题 2026-04-21 02:06:28 +08:00
pluv a610904290 style: 格式化代码 2026-04-21 00:17:03 +08:00
pluv c0483b87f8 fix: 优化 CSS 结构 2026-04-21 00:15:57 +08:00
pluv e943b1b7d7 fix: 修复 Lict 问题 2026-04-20 18:55:25 +08:00
pluv 2a47cad2d5 perf: 脏标记优化 Lict 对象操作复杂度 2026-04-20 18:43:27 +08:00
pluv 417588acea feat: 用户界面实现算法共存与切换 2026-04-20 17:50:36 +08:00
pluv 65fbdec0a9 feat: 代码格式化, 改进仪表盘, 新增多CSS支持 2026-04-20 16:30:04 +08:00
pluv 845a505ca1 feat: 试增加单元集独立设置 2026-04-20 07:39:57 +08:00
pluv d16ec03da6 feat: 增加 ZMQ 调试服务器并完善设置功能 2026-04-20 06:37:46 +08:00
pluv 41af2ada45 feat: 补全设置提示与完善设置页 2026-04-20 05:05:44 +08:00
pluv b3f95861f0 feat(interface): 图形化设置页原型 2026-04-20 03:33:05 +08:00
pluv 4a4237acbd style: 删除旧配置文件 2026-04-20 01:46:40 +08:00
pluv a38fd3d398 refactor: 对配置处理器和配置结构进行重构 2026-04-20 01:44:43 +08:00
pluv 5c43059518 fix: 变更哈希注册表以修复重复键造成的崩溃 2026-04-19 12:08:58 +08:00
pluv 053c4660d0 feat: FAST-0 特殊算法 2026-04-19 00:05:49 +08:00
pluv 215a8648c2 style: 移除设计不当的部分模块 2026-04-16 13:20:12 +08:00
pluv f7c072dd0b build: 修改依赖并引入外部库 2026-03-27 19:31:40 +08:00
pluv 230a76fe27 feat(interface): 改进界面 2026-01-28 03:35:58 +08:00
pluv 26e053f79f feat(interface): 改进仪表盘 2026-01-22 06:13:01 +08:00
pluv 76c9537ff0 fix: 修复若干问题 2026-01-17 05:45:45 +08:00
pluv 56f5dc076e fix(interface): 修复按钮标识和一个哈希问题 2026-01-15 12:49:57 +08:00
pluv 3f7bbcdc19 feat(interface): 改进状态显示 2026-01-13 23:39:10 +08:00
pluv 4cb6edf073 build: 使用 uv 包管理器 2026-01-09 00:47:34 +08:00
pluv 264af76fad fix: 改进 2026-01-08 01:00:08 +08:00
pluv c9cc8a7917 fix: 小范围修补 2026-01-08 00:56:04 +08:00
pluv 6d60f95d6a feat: 一系列新功能 2026-01-08 00:05:00 +08:00
pluv d3dadced85 feat(interface): 完成队列式记忆模块更新 2026-01-06 20:32:27 +08:00
pluv 9d9858788c fix: 修复完成屏幕问题 2026-01-06 19:58:46 +08:00
pluv 3266eda44d fix: 修正逻辑问题 2026-01-06 18:28:57 +08:00
pluv 4dd8e373b2 feat: 更新状态机 2026-01-05 05:25:14 +08:00
pluv 1d0c87b5f7 fix: 改进代码 2026-01-04 04:46:19 +08:00
pluv b8b1c5e0d7 fix(interface): 界面兼容性改进 2026-01-04 04:14:57 +08:00
pluv 4d494546b8 fix(interface): 完成主要屏幕重构 2026-01-04 03:46:45 +08:00
pluv 5a825f6eb3 fix: 部分修复重构数据格式差异 2026-01-04 02:11:07 +08:00
pluv 10f541325b feat(reactor): 状态机进一步改进 2026-01-04 00:28:44 +08:00
pluv 7f36450079 feat: 改进状态机 2026-01-03 13:08:08 +08:00
pluv 32cd44b8e7 feat(kernel): 状态机改进 2026-01-03 05:05:41 +08:00
pluv f2d91b0164 feat: 完成部分界面重构 2026-01-02 06:12:49 +08:00
pluv bd313db5f2 feat: 改进对象系统 2026-01-01 20:18:18 +08:00
pluv d25e398701 feat: 基本完成对象系统更新 2026-01-01 16:19:46 +08:00
pluv af63cceba9 feat: 改进粒子对象 2026-01-01 06:36:27 +08:00
pluv 8358e2532a feat: 进一步改进 2025-12-31 00:57:07 +08:00
pluv 8828db24b5 feat: 一些改进 2025-12-29 21:53:20 +08:00
pluv ecefc53a20 refactor: 部分更改数据结构 2025-12-28 06:16:21 +08:00
pluv e562e02227 style: 格式化代码 2025-12-21 23:44:13 +08:00
pluv c10a18bafd feat(interface): 增加智能单元集排序 2025-12-21 23:42:02 +08:00
pluv 6a5120920a fix: 暂时禁用实验性功能 2025-12-21 23:06:17 +08:00
pluv 56038afefe refactor(synctool): 改进同步方案 2025-12-21 21:18:31 +08:00
pluv d4dce68449 feat(synctool): 虚拟文件系统初步方案 2025-12-21 18:48:25 +08:00
pluv 8dd323a789 style: 格式化代码 2025-12-21 07:56:10 +08:00
pluv 531b0756bf feat(synctool): 增加同步功能 2025-12-21 07:49:19 +08:00
pluv 058cc1e4d8 feat(kernel): 添加算法切换设置 2025-12-21 06:48:30 +08:00
pluv 7e28e0cd6b style: 更新版本号和合并规则 2025-12-21 06:34:17 +08:00
pluv a3612bd3b3 fix(interface): 修复仪表盘详情 2025-12-21 06:27:00 +08:00
pluv 77441e2d40 feat(interface): 更改启动方式 2025-12-21 06:06:16 +08:00
pluv 72697f652f fix(interface): 修复显示问题 2025-12-21 05:47:22 +08:00
pluv c5fd625c87 feat: 自动音频播放与改进设计 2025-12-21 05:32:58 +08:00
pluv 91d3c86871 style(version): 更新版本号 2025-12-21 03:02:29 +08:00
pluv 66ad50c44d feat: 实验性 SM-15M 算法实现
实验性 SM-15M 逆向工程算法实现
2025-12-21 02:15:23 +08:00
pluv 2527daa923 style: isort 格式化 2025-12-19 15:13:42 +08:00
pluv 1883ca2387 style: 格式化代码 2025-12-19 15:08:26 +08:00
pluv dde6f872f0 fix(interface): 修复默认配置文件 2025-12-18 15:53:18 +08:00
203 changed files with 15086 additions and 4190 deletions
+29 -159
View File
@@ -1,36 +1,19 @@
# Project specific additions
.devflag
.vscode/
.directory
__pycache__/
.idea/
cache/
#nucleon/test.toml
electron/test.toml
*.egg-info/
build/
dist/
old/
# HeurAMS specific rules
*.log.*
*.log
*.pkl
/data
# Project specific directories
# config/
data/cache/
data/electron/
data/nucleon/
!data/nucleon/test*
data/orbital/
AGENTS.md
# Editor
.vscode/
.idea/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
@@ -49,16 +32,6 @@ share/python-wheels/
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
@@ -73,73 +46,10 @@ coverage.xml
.hypothesis/
.pytest_cache/
cover/
.claude/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly used for packaging.
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
#pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
@@ -149,72 +59,32 @@ ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
## KDE Dolphin
.directory
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
.idea/
# Audio cache and temporary files
*.mp3
*.wav
*.ogg
*.tmp
# LLM cache files
*.cache
*.jsonl
# Log files
*.log
logs/
# OS generated files
# macOS
.DS_Store
.DS_Store?
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
.AppleDB
.AppleDesktop
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
Desktop.ini
ehthumbs_vista.db
[Dd]esktop.ini
$RECYCLE.BIN/
*.lnk
# Linux
*~
# VSCode
.vscode/
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Temporary files
*.tmp
*.temp
# Other trash
AGENT.md
# It's not AGENTS.md
+50
View File
@@ -0,0 +1,50 @@
# AI Coding Assistant Guide
This document provides guidance for AI tools and developers using AI assistance to contribute code to the HeurAMS project. Generally, this file will be automatically loaded into the context of various AI tools.
AI tools should read this `/AGENTS.md` file in its entirety.
## Reference Documents
When assisting with HeurAMS development, AI tools should follow standard development practices and procedures. They should automatically check, or check upon user issuing an "init/初始化" command:
- [Contributing Guide](/CONTRIBUTING.md)
- [README](/README.md)
- [Architecture Overview](/docs/ARCHITECTURE.md)
## Prohibited Actions
1. AI must not automatically generate PR or patch files
2. AI must not modify existing code without human confirmation
3. AI must not generate formatted files without using formatting tools
4. AI must not fix any "bugs" without human confirmation
5. AI must not engage in any "cowboy coding" that bypasses project design principles or creates separate libraries
6. AI must not directly operate pip, uv, apt, or other tools to modify external dependencies or tools — human developers should handle dependencies themselves
7. AI must not write new comments in a language different from the existing comment language of any existing file
8. AI must not overwrite files without reading them first
9. **Modification of this `/AGENTS.md` file is absolutely forbidden**
## License & Legal Requirements
All contributions must comply with licensing requirements. All code must be compatible with the AGPL-3.0-or-later license and the project's additional exemption clause (located at lines 237-245 at the end of the LICENSE file).
## Signed-off-by & DCO
AI agents are **strictly forbidden** from adding Signed-off-by tags.
Only humans can legally certify the DCO.
Human committers are responsible for:
- Reviewing all AI-generated code
- Ensuring compliance with licensing requirements
- Adding their own Signed-off-by tag to certify the DCO
- Taking responsibility for contributions
AI assistants are responsible for:
- Understanding the runtime environment, such as the operating system or specific distribution
- Following the rules outlined in this document
- Proactively reminding developers using AI tools
This document references <a href="https://docs.kernel.org/process/coding-assistants.html" target="_blank" rel="noopener noreferrer">AI Coding Assistants — The Linux Kernel documentation</a>
+105 -25
View File
@@ -1,39 +1,119 @@
# 贡献指南
# Contributing Guide & Development
欢迎为此项目做出贡献!
本项目是一个开源项目, 我们鼓励社区成员参与改进.
Welcome and thank you for supporting this project!
## 开发流程
Currently, the primary project repository server is <a href="https://git.pluv27.top/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">git.pluv27.top</a>, which manages synchronization, ensures availability, and accepts collaboration from multiple communities. Mirror syncs are set up on <a href="https://github.com/pluvium27/HeurAMS" target="_blank" rel="noopener noreferrer">GitHub</a>, <a href="https://invent.kde.org/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">KDE Invent</a>, and <a href="https://gitee.com/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">Gitee</a>.
1. **分支划分**:
- `main` 分支: 稳定版本
- `dev` 分支: 开发版本
- 功能分支: 从 `dev` 分支创建, 命名格式为 `feature/描述``fix/描述``refactor/描述`
2. **代码风格**:
- 请使用 Black 格式化代码
- 遵循 PEP 8 规范
- 添加适当的文档字符串
3. **提交消息**:
- 使用简体中文或英文撰写清晰的提交消息
- 格式: 遵循 Conventional Commits 规范
This does not affect the project's acceptance of PRs from <a href="https://github.com/pluvium27/HeurAMS" target="_blank" rel="noopener noreferrer">GitHub</a>, <a href="https://invent.kde.org/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">KDE Invent</a>, and <a href="https://gitee.com/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">Gitee</a>. PRs accepted on GitHub, KDE Invent, and Gitee will retain contributor attribution and be synced back to all platforms as-is. Contributions on any platform are welcome.
## 设置开发环境
> [!NOTE]
> We have begun development of a modern cross-platform frontend based on the KDE UI framework `Kirigami`, called "KiriMemo" (package name "org.kde.kirimemo"). Note that it is not a KDE project.\
> It directly reuses the Python kernel via `PyOtherSide`, providing a modern UI for Windows, Linux, macOS, Android, iOS, and Plasma Mobile.\
> If you are skilled in C++, QML, Qt, and KDE frameworks, you are welcome to join the KiriMemo project development.
## Development Conventions
Branch structure:
- `dev` branch (default branch): Mainline development branch. Used only for non-refactoring bug fixes and integration of feature branches. Pull requests are merged into this branch.
- `master` branch: Mainline stable version. `dev` is merged into `master` only when a stable version is released or a patch version is prepared.
- Feature and refactoring branches: Created from `dev`, named as `feature/description` or `fix/description` or `refactor/description` or `next/version`.
- Feature and refactoring branches should first be merged into `dev`, then into `master`.
Code formatting:
- Install tools:
```bash
python -m pip install black autoflake mdformat
```
- For Python, use `black` and `autoflake`.\
Commands:
```bash
# black's multi-threading may have compatibility issues in some environments
black . --workers=1
```
```bash
# autoflake: note to exclude __init__.py
autoflake --in-place --remove-all-unused-imports --recursive ./src/ --exclude __init__.py
```
- For Markdown, use `mdformat`.\
Command:
```bash
mdformat --number .
```
- For Textual CSS, use `prettier`.
- Formatting is not mandatory and can be consolidated into a single `style` commit, but code on `master` and `dev` branches should be kept clean for review.
Commit messages:
- Write clear commit messages in Simplified Chinese or English.
- Commit message format: Follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. The `koji` tool is recommended.
Merge method:
- For consistency and traceability, Fast-forward merges have been prohibited since the repository was re-initialized after the v0.4.0 refactoring.
- You can set `git config merge.ff false`.
Commit author format:\
Due to the quirky git hook on KDE Invent infrastructure, the commit Author field must appear to be a real name (e.g., "Wang Zhiyu" instead of "wangzhiyu"; otherwise, KDE Invent's hook will reject the push).\
Therefore, please ensure your git configuration uses a proper name-like format (e.g., `git config user.name "Li Hua"`, i.e., must include a space). A real name is not required; the email field has no restrictions. You can also repeat a single name twice (e.g., "Thura Thura") to pass the check.
## Setting Up Development Environment
```bash
# 克隆仓库
git clone https://gitea.imwangzhiyu.xyz/ajax/HeurAMS
git clone https://git.pluv27.top/pluv/HeurAMS # Default branch is dev, no need to switch
cd HeurAMS
# 你可能需要切换分支
# If using uv (recommended)
# 安装依赖
pip install -r requirements.txt
python3 -m pip install uv
# 安装开发版本
pip install -e .
uv sync --all-extras # Sync development environment
uv run heurams
# If using native Python environment (not recommended, but retained for environments where uv and hardlinks are not supported, e.g., termux)
python3 -m pip install -e .[all] # Install dependencies and HeurAMS as a local package
python3 -m heurams # Verify installation
python3 -m heurams.interface # Launch TUI
```
## 许可证
## License & External References
贡献者同意其贡献将在 AGPL-3.0 许可证下发布.
Contributors retain copyright of their contributions and agree that their contributions will be released under the AGPL-3.0 license (including the additional local API call exemption clause).
Please note in your PR description if:
- You need to introduce other open-source vendor code
- You need to introduce other proprietary online services (e.g., edgetts in the current project)
- You need to upgrade a dependency or runtime environment version
## New User Interface Frontends
HeurAMS is designed as a frontend-independent library, meaning:
- Our built-in Textual TUI frontend is not the only available frontend.
- If you have developed a usable HeurAMS frontend (e.g., a not-yet-implemented Flutter frontend) and open-sourced it under AGPL-3.0/GPL-3.0, you can contact us to transfer it to the HeurAMS official repository for joint maintenance. You will retain your copyright and can lead development under that repository. :)
- You can also call HeurAMS as an independent process/service in your own projects. Per AGPL-3.0 and this project's additional license terms, if the call occurs on the same host and does not involve external network forwarding, it is exempt from specific obligations of the license and will not be "contaminated" by AGPL-3.0. To this end, we are improving the optional cross-process RPC module, which will become a cross-platform standard component of the HeurAMS kernel.
- If you develop another piece of software through independent process/service invocation and open-source it but prefer not to use the AGPL-3.0/GPL-3.0 license, you can also contact us. We would be happy to add your project link to our friendly links.
## Non-Software Contributions
Even if you are not a software developer, you are welcome to contribute!
You can:
- Help create or proofread translations for the software interface and documentation in various languages
- Create open memory unit sets (including but not limited to text, images, and audio) for other users
- Improve the software documentation
- Answer questions from other users or share your experience
- Propose new ideas or report issues in the discussion area
You decide your role!
+119
View File
@@ -0,0 +1,119 @@
# 贡献指南与二次开发
欢迎支持此项目!
目前, 项目仓库主服务器为<a href="https://git.pluv27.top/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">作者的 Gitea 实例</a>, 它负责管理同步, 保证可用性并同时接受来自多个社区的协作, 并在 <a href="https://github.com/pluvium27/HeurAMS" target="_blank" rel="noopener noreferrer">GitHub</a>, <a href="https://invent.kde.org/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">KDE Invent</a> 和 <a href="https://gitee.com/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">Gitee</a> 设置了镜像同步.
这丝毫不影响项目接受来自 <a href="https://github.com/pluvium27/HeurAMS" target="_blank" rel="noopener noreferrer">GitHub</a>, <a href="https://invent.kde.org/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">KDE Invent</a> 和 <a href="https://gitee.com/pluv/HeurAMS" target="_blank" rel="noopener noreferrer">Gitee</a> 的 PR, 在 GitHub, KDE Invent 和 Gitee 所接受的 PR 会保留贡献者标识并按原样同步回所有平台, 欢迎在任意平台为项目做出贡献.
> [!NOTE]
> 我们已经开始着手于基于 KDE 用户界面框架 `Kirigami` 的现代跨平台前端开发, 称作 "KiriMemo", 包名是 "org.kde.kirimemo", 但其并非 KDE 项目\
> 它通过 `PyOtherSide` 直接复用 python 内核, 为 Windows, Linux, macOS, Android, iOS 和 Plasma Mobile 提供现代用户界面
> 如果您善于开发 C++, QML, Qt 与 KDE 框架, 欢迎加入到 KiriMemo 项目的开发
## 开发规范
分支划分:
- `dev` 分支(仓库默认分支): 主线开发分支, 自身仅用于非重构的问题修复和整合功能分支, 拉取请求在该分支合并
- `master` 分支: 主线稳定版本, 仅当稳定版本释出或修补版本时将 `dev` 合并到 `master`
- 功能与重构分支: 从 `dev` 分支创建, 命名格式为 `feature/描述``fix/描述``refactor/描述``next/版本号`
- 功能与重构分支应先合并至 `dev`, 再合并至 `master`
代码格式化:
- 安装工具:
```bash
python -m pip install black autoflake mdformat
```
- 对于 Python, 使用 `black` 与 `autoflake` 格式化\
命令:
```bash
# black 的多线程在某些环境下有兼容性问题
black . --workers=1
```
```bash
# autoflake 注意排除 __init__.py
autoflake --in-place --remove-all-unused-imports --recursive ./src/ --exclude __init__.py
```
- 对于 Markdown, 使用 `mdformat` 格式化
命令:
```bash
mdformat --number .
```
- 对于 Textual CSS, 可以使用 `prettier` 格式化
- 格式化不是必需的, 可以整合入一次 `style` 提交, 但 `master` 和 `dev` 分支上的代码应尽量整洁, 以便合并时审查
提交消息:
- 使用简体中文或英文撰写清晰的提交消息
- 提交消息格式: 遵循 [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 规范, 建议使用 `koji` 工具
合并方式:
- 为了一致性和可追溯性, 项目自 v0.4.0 重构后重新初始化仓库起就禁止使用 Fast-forward 合并
- 可以设置 `git config merge.ff false`
提交署名方式:
由于 KDE Invent 设施的奇怪 git hook, commit 的 Author 字段需要看起来像个真名(例如 Wang Zhiyu 不能写为 wangzhiyu, 否则 KDE Invent 的 hook 会拒绝 push),
所以请确保您的 git 配置使用了类似正式姓名的格式 (例如 git config user.name "Li Hua", 也即中间需要空格), 不一定要真实姓名, 邮箱无要求, 也可以将单名重复使用两次 (例如 Thura Thura) 以通过检测.
## 设置开发环境
```bash
git clone https://git.pluv27.top/pluv/HeurAMS # 默认分支为 dev, 所以不必切换分支
cd HeurAMS
# 如果决定使用 uv (推荐)
python3 -m pip install uv
uv sync --all-extras # 同步开发运行环境
uv run heurams
# 如果决定使用原生 python 环境 (不推荐, 但我们保留了这种方式以便在不便支持 uv 与硬链接的环境和文件系统(例如 termux)上运行)
python3 -m pip install -e .[all] # 安装依赖并将 HeurAMS 安装为本地包
python3 -m heurams # 验证安装
python3 -m heurams.interface # 启动 TUI
```
## 许可证与外部引用
贡献者拥有其贡献部分的版权同意其贡献将在 AGPL-3.0 许可证(包括附加的本机 API 调用豁免条款)下发布.
如有以下情况, 请在 PR 描述中注明:
- 如果需要引入其他开源 vendor
- 如果需要引入其他专有的网络服务(例如当前项目中的 edgetts)
- 如果需要升级某个依赖或运行环境的版本
## 新的用户界面前端
HeurAMS 被设计为一个可独立于前端的程序库, 这意味着:
- 我们的内置 Textual TUI 前端不是唯一可用的前端
- 如果您有一个自己开发的且可用的 HeurAMS 前端 (例如未实现的 Flutter 前端), 并且以 AGPL-3.0/GPL-3.0 开放源代码, 可以联系我们将它转移到 HeurAMS 的官方仓库中以便共同维护, 您将保留您的版权并可主导该仓库下的开发工作 :)
- 您还可以在自己的项目中以独立进程/服务调用 HeurAMS, 根据 AGPL-3.0 及本项目的附加许可条款, 如果调用发生在同一主机上且不涉及外部网络转发, 则可豁免许可证规定的特定义务而免于受 AGPL-3.0 "污染". 为了这点, 我们正在完善可选择启用的跨进程 RPC 模块, 这将成为潜进内核的跨平台标准件.
- 如果您通过独立进程/服务调用方式开发了另外的软件, 开源但不愿使用 AGPL-3.0/GPL-3.0 许可证, 也可以联系我们, 我们乐于将您的项目链接添加到友链中
## 软件开发之外的贡献
即使您不是软件开发人员, 我们也欢迎您加入贡献!
您可以:
- 协助创建或核对各种语言的翻译来翻译软件的界面和文档
- 制作开放的记忆单元集(包括但不限于文字、图像、音效)给其他用户使用
- 改进软件配套的文档
- 给其他用户答疑解惑或分享自己的经验
- 在讨论区提出新想法或反馈问题
您的角色您来定!
+205 -621
View File
@@ -1,661 +1,245 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
The precise terms and conditions for copying, distribution and
modification follow.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
A "covered work" means either the unmodified Program or a work based on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
1. Source Code.
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
HeurAMS
Copyright (C) 2025 Wang Zhiyu (pluvium27)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <http://www.gnu.org/licenses/>.
---
Additional Permission under AGPL Section 7
Local API exemption: When the program is accessed solely via API (including RPC, REST API, GraphQL, or any machine-to-machine interface) from a caller on the same host machine, the source disclosure requirement of AGPL Section 13 is waived.
Any API call from a different machine (including LAN, private network, or the internet) remains fully subject to AGPL Section 13.
This exemption does NOT apply to human-facing web interfaces.
+121 -160
View File
@@ -1,182 +1,143 @@
# 潜进 (HeurAMS) - 启发式辅助记忆程序
# HeurAMS - Heuristic Auxiliary Memorizing Scheduler
## 概述
"潜进" (HeurAMS: Heuristic Auxiliary Memorizing Scheduler, 启发式记忆辅助调度器) 是为习题册, 古诗词, 及其他问答/记忆/理解型知识设计的多用途辅助记忆软件, 提供动态规划的优化记忆方案
[中文](README_zh.md) | English
## 关于此仓库
"潜进" 软件组项目包含多个子项目
此仓库包含了 "潜进" 项目的核心和基于 Textual 的基本用户界面的实现
## Overview
## 开发进程
- 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 构建酷酷的现代化前端, 增加云同步/文档源服务...
HeurAMS "潜进" (Heuristic Auxiliary Memorizing Scheduler) is an auxiliary memorizing scheduler based on heuristic algorithms and cognitive science theories, designed to help users memorize and plan learning more efficiently.
It is also an open, elegant, and extensible spaced repetition scheduler experiment platform, intended to help researchers conduct investigations, experiments, and research on cutting-edge memory algorithms more efficiently.
## 特性
[Detailed Introduction](docs/INTRODUCTION.md) | [Screenshots](docs/SCREENSHOTS.md)
### 间隔迭代算法
> 许多出版物都广泛讨论了不同重复间隔对学习效果的影响. 特别是, 间隔效应被认为是一种普遍现象. 间隔效应是指, 如果重复的间隔是分散/稀疏的, 而不是集中重复, 那么学习任务的表现会更好. 因此, 有观点提出, 学习中使用的最佳重复间隔是**最长的、但不会导致遗忘的间隔**.
- 采用经实证的 SM-2 间隔迭代算法, 此算法亦用作 Anki 闪卡记忆软件的默认闪卡调度器
- 动态规划每个记忆单元的记忆间隔时间表
- 动态跟踪记忆反馈数据, 优化长期记忆保留率与稳定性
<p align="left">
<a href="https://github.com/pluvium27/HeurAMS" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/GitHub-fafafa?style=for-the-badge&logo=github&logoColor=181717" alt="GitHub" /></a><a href="https://invent.kde.org/pluv/HeurAMS" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/KDE_Invent-1D99F3?style=for-the-badge&logo=kde&logoColor=white" alt="KDE Invent" /></a><a href="https://gitee.com/pluv/HeurAMS" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Gitee-C71D23?style=for-the-badge&logo=gitee&logoColor=white" alt="Gitee" /></a><a href="https://git.pluv27.top/pluv/HeurAMS" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/git.pluv27.top-609926?style=for-the-badge&logo=gitea&logoColor=white" alt="git.pluv27.top" /></a>
</p>
### 学习进程优化
- 逐字解析: 支持逐字详细释义解析
- 语法分析: 接入生成式人工智能, 支持古文结构交互式解析
- 自然语音: 集成微软神经网络文本转语音 (TTS) 技术
- 多种谜题类型: 选择题 (MCQ)、填空题 (Cloze)、识别题 (Recognition)
- 动态内容生成: 支持宏驱动的模板系统, 根据上下文动态生成题目
## Quick Start
### 实用用户界面
- 响应式 Textual 框架构建的跨平台 TUI 界面
- 支持触屏/鼠标/键盘多操作模式
- 简洁直观的复习流程设计
### Installation
### 架构特性
- 模块化设计: 算法、谜题、服务提供者可插拔替换
- 上下文管理: 使用 ContextVar 实现隐式依赖注入
- 数据持久化: TOML 配置与内容, JSON 算法状态
- 服务抽象: 音频播放、TTS、LLM 通过 provider 架构支持多种后端
- 完整日志系统: 带轮转的日志记录, 便于调试
#### Install from Package Manager
## 安装
HeurAMS (package name `heurams`) is in early development and not yet available on PyPI.
However, you can install stable and development versions from the repository using pip, which requires a Python environment (Python 3.12.13 or later recommended).
### 从源码安装
1. 克隆仓库:
```bash
git clone https://gitea.imwangzhiyu.xyz/ajax/HeurAMS
cd HeurAMS
```
Install from the stable `master` branch with optional dependencies for user experience (recommended):
2. 安装依赖:
```bash
pip install -r requirements.txt
```
3. 以开发模式安装包:
```bash
pip install -e .
```
## 使用
### 启动应用
```bash
# 在任一目录(建议是空目录或者包根目录, 将被用作存放数据)下运行
python -m heurams.interface
```
pip install --upgrade 'heurams[basic] @ https://git.pluv27.top/pluv/HeurAMS/archive/master.zip'
```
### 数据目录结构
应用会在工作目录下创建以下数据目录:
- `data/nucleon/`: 记忆内容 (TOML 格式)
- `data/electron/`: 算法状态 (JSON 格式)
- `data/orbital/`: 策略配置 (TOML 格式)
- `data/cache/`: 音频缓存文件
- `data/template/`: 内容模板
Install from the more recent, roughly stable `dev` branch with optional dependencies (if you want cutting-edge improvements):
首次运行时会自动创建这些目录.
## 配置
配置文件位于 `config/config.toml`(相对于工作目录). 如果不存在, 会使用内置的默认配置.
## 项目结构
### 架构图
以下 Mermaid 图展示了 HeurAMS 的主要组件及其关系:
```mermaid
graph TB
subgraph "用户界面层 (TUI)"
TUI[Textual TUI]
Widgets[界面组件]
Screens[应用屏幕]
end
subgraph "服务层"
Config[配置管理]
Logger[日志系统]
Timer[时间服务]
AudioService[音频服务]
TTSService[TTS服务]
OtherServices[其他服务]
end
subgraph "内核层"
Algorithms[算法模块]
Particles[数据模型]
Puzzles[谜题模块]
Reactor[调度反应器]
end
subgraph "提供者层"
AudioProvider[音频提供者]
TTSProvider[TTS提供者]
OtherProviders[其他提供者]
end
subgraph "数据层"
Files[本地文件数据]
end
subgraph "上下文管理"
Context[ConfigContext]
CtxVar[config_var]
end
TUI --> Config
TUI --> Logger
TUI --> AudioService
TUI --> TTSService
TUI --> OtherServices
Config --> Files
Config --> Context
AudioService --> AudioProvider
TTSService --> TTSProvider
OtherServices --> OtherProviders
Reactor --> Algorithms
Reactor --> Particles
Reactor --> Puzzles
Particles --> Files
Algorithms --> Files
```
pip install --force-reinstall --no-deps 'heurams[basic] @ https://git.pluv27.top/pluv/HeurAMS/archive/dev.zip'
```
### 目录结构
Install the general audio module for desktop computers (based on playsound3):\
(Not applicable for termux environments; termux has built-in audio support)
```
src/heurams/
├── __init__.py # 包入口点
├── context.py # 全局上下文、路径、配置上下文管理器
├── services/ # 核心服务
│ ├── config.py # 配置管理
│ ├── logger.py # 日志系统
│ ├── timer.py # 时间服务
│ ├── audio_service.py # 音频播放抽象
│ └── tts_service.py # 文本转语音抽象
├── kernel/ # 核心业务逻辑
│ ├── algorithms/ # 间隔重复算法 (FSRS, SM2)
│ ├── particles/ # 数据模型 (Atom, Electron, Nucleon, Orbital)
│ ├── puzzles/ # 谜题类型 (MCQ, cloze, recognition)
│ └── reactor/ # 调度和处理逻辑
├── providers/ # 外部服务提供者
│ ├── audio/ # 音频播放实现
│ ├── tts/ # 文本转语音实现
│ └── llm/ # LLM 集成
├── interface/ # Textual TUI 界面
│ ├── widgets/ # UI 组件
│ ├── screens/ # 应用屏幕
│ └── __main__.py # 应用入口点
└── default/ # 默认配置和数据模板
pip install --upgrade 'heurams[audio-playsound] @ https://git.pluv27.top/pluv/HeurAMS/archive/master.zip'
```
## 贡献
> You can also install from specific branches like `refactor/...` to test particular changes.
欢迎贡献!请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南.
[Dependency Group Reference](docs/INTRODUCTION.md#package-dependency-groups)
## 许可证
#### Install from Source
本项目基于 AGPL-3.0 许可证开源. 详见 [LICENSE](LICENSE) 文件.
We provide two source installation methods: native Python and uv.
See the [Contributing Guide - Setting up Development Environment](CONTRIBUTING.md#setting-up-development-environment) for details.
### Usage
Run `heurams` in your terminal, and you will see help information:
```plain
~ $ heurams
Usage: heurams [OPTIONS] COMMAND [ARGS]...
HeurAMS 0.5.1 - Heuristic Auxiliary Memorizing Scheduler
Options:
-v, --version Show the version and exit.
-h, --help Show this message and exit.
Commands:
help Show this help message
tui Launch the built-in basic user interface (TUI)
version Print version information
```
Start the basic user interface by typing `heurams tui`:
```plain
~ $ heurams tui
Welcome to the basic user interface!
Loading config and context... Done! (2ms)
Loading UI framework... Done! (89ms)
Loading UI layout... Done! (56ms)
Component directory: <package directory>
Working directory: <working directory, ./data folder will be created here>
Pre-work total: 147ms
(Your terminal will now display the TUI)
```
Check the version with `heurams -v`:
```
~ $ heurams -v
HeurAMS 0.5.1 stable (fulcrum/支点), Linux
```
## Frequently Asked Questions (FAQ)
See [FAQ](docs/FAQ.md).
## Project Architecture
See [Architecture Overview](docs/ARCHITECTURE.md).
## Contributing
Contributions are welcome!
See the [Contributing Guide](CONTRIBUTING.md).
For AI-assisted development guidelines, see [AGENTS.md](AGENTS.md).
## Project Identity
HeurAMS project identity assets are located in `./src/heurams/assets/art/` directory:
<img src="src/heurams/assets/art/banner128-light.png" height="96px" title="Bitmap Banner (Opaque)">
<div style="display: flex; flex-wrap: wrap; gap: 5px;">
<img src="src/heurams/assets/art/logo.svg" height="96px" title="Vector Icon">
<img src="src/heurams/assets/art/logo-mono-light.svg" height="96px" title="Monochrome Light Vector Icon">
<img src="src/heurams/assets/art/logo-mono-dark.svg" height="96px" title="Monochrome Dark Vector Icon">
</div>
Colors: `#1660A5 (Ocean Blue)` `#545F70 (Blue Gray)` `#FFFFFF (Monochrome Light Icon White)` `#1A1A1A (Monochrome Dark Icon Deep Black)` `#2f2f35 (Text Color)`.
## License
### Project
This project is open source under the AGPL-3.0 license, with an additional exemption clause for local API calls, making it more permissive than the standard AGPL-3.0.
See the [LICENSE](LICENSE) file in the root directory.
### Third-Party Code
The project embeds or directly uses the following third-party code or its derivatives (possibly with modifications) in `src/heurams/vendor/` or other locations:
#### SM.js
- Upstream version: commit `6e3bb4a` (upstream discontinued as of Feb 4, 2015)
- Usage: Rewritten from CoffeeScript to Python with indirect reference, maintaining the same mathematical principles; improved logic, performance, and standardized API
- Location: `src/heurams/kernel/algorithms/sm15m.py`
- Original project: [SM.js](https://github.com/slaypni/SM-15)
- Original copyright: Copyright (c) 2014 Kazuaki Tanida
- Original license: MIT License
This project benefits from their selfless and excellent work.
+144
View File
@@ -0,0 +1,144 @@
# HeurAMS "潜进" - 启发式辅助记忆调度器
中文 | [English](README.md)
## 概述
HeurAMS "潜进" (Heuristic Auxiliary Memorizing Scheduler, 启发式记忆辅助调度器) 是一种基于启发式算法与认知科学理论的辅助记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划,
也是一种开放, 优雅, 易于扩展的间隔重复调度器实验平台, 旨在帮助研究者更高效地进行前沿记忆算法的调查实验与研究.
[详细介绍](docs/INTRODUCTION_zh.md) [屏幕截图](docs/SCREENSHOTS_zh.md)
<p align="left">
<a href="https://github.com/pluvium27/HeurAMS" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/GitHub-fafafa?style=for-the-badge&logo=github&logoColor=181717" alt="GitHub" /></a><a href="https://invent.kde.org/pluv/HeurAMS" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/KDE_Invent-1D99F3?style=for-the-badge&logo=kde&logoColor=white" alt="KDE Invent" /></a><a href="https://gitee.com/pluv/HeurAMS" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Gitee-C71D23?style=for-the-badge&logo=gitee&logoColor=white" alt="Gitee" /></a><a href="https://git.pluv27.top/pluv/HeurAMS" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/git.pluv27.top-609926?style=for-the-badge&logo=gitea&logoColor=white" alt="git.pluv27.top" /></a>
</p>
## 快速开始
### 安装软件
#### 从包管理器安装
潜进 (包名是 `heurams`) 处于早期开发考虑, 尚未上架 PyPI.
但可以用 pip 从仓库安装稳定版和开发版本, 这要求设备上安装了 python 环境 (建议 3.12.13 及之后版本).
从稳定的 `master` 分支安装, 并安装适用于用户体验的可选依赖(推荐):
```
pip install --upgrade 'heurams[basic] @ https://git.pluv27.top/pluv/HeurAMS/archive/master.zip'
```
从较前沿, 大致稳定的 `dev` 分支安装, 并安装适用于用户体验的可选依赖(如果您追求较前沿的改进):
```
pip install --force-reinstall --no-deps 'heurams[basic] @ https://git.pluv27.top/pluv/HeurAMS/archive/dev.zip'
```
安装适用于一般计算机的通用音频模块 (基于 playsound3):\
(此项不适用于 termux 环境, termux 的音频支持是内建的)
```
pip install --upgrade 'heurams[audio-playsound] @ https://git.pluv27.top/pluv/HeurAMS/archive/master.zip'
```
> 您也可以从 `refactor/...` 等特定分支安装以测试某项更改
[依赖分组说明](docs/INTRODUCTION_zh.md#包依赖组说明)
#### 从源码安装
我们提供原生 python 和 uv 两种源码安装方式.\
详见[贡献指南 - 设置开发环境](CONTRIBUTING_zh.md#设置开发环境).
### 使用软件
在终端中运行 `heurams`, 您会看到一系列帮助信息, 例如:
```plain
~ $ heurams
Usage: heurams [OPTIONS] COMMAND [ARGS]...
HeurAMS 0.5.1 - 启发式辅助记忆调度器
Options:
-v, --version Show the version and exit.
-h, --help Show this message and exit.
Commands:
help 显示此帮助信息
tui 启动内置基本用户界面 (TUI)
version 输出版本信息
```
可以通过键入 `heurams tui` 启动基本用户界面, 例如:
```plain
~ $ heurams tui
欢迎使用基本用户界面!
加载配置与上下文... 已完成! (耗时: 2ms)
加载用户界面框架... 已完成! (耗时: 89ms)
加载用户界面布局... 已完成! (耗时: 56ms)
组件目录: <软件包所在目录>
工作目录: <运行目录, 将在此目录下建立 ./data 文件夹>
前置工作共计耗时: 147ms
(此时您的终端将转为呈现美观的 TUI 基本用户界面)
```
通过键入 `heurams -v` 查看版本:
```
~ $ heurams -v
HeurAMS 0.5.1 stable (fulcrum/支点), Linux
```
## 常见问题 (FAQ)
详见[常见问题](docs/FAQ_zh.md).
## 项目架构
详见[架构说明](docs/ARCHITECTURE_zh.md).
## 参与项目
欢迎参与到项目协作中!\
详见[贡献指南](CONTRIBUTING_zh.md).\
关于 AI 辅助开发的说明, 请参阅 [AGENTS.md](AGENTS.md).
## 项目标识
HeurAMS 项目标识如下, 文件(位图和矢量图)位于 `./src/heurams/assets/art/` 目录.
<img src="src/heurams/assets/art/banner128-light.png" height="96px" title="位图横幅(不透明)">
<div style="display: flex; flex-wrap: wrap; gap: 5px;">
<img src="src/heurams/assets/art/logo.svg" height="96px" title="矢量图标">
<img src="src/heurams/assets/art/logo-mono-light.svg" height="96px" title="单色明亮矢量图标">
<img src="src/heurams/assets/art/logo-mono-dark.svg" height="96px" title="单色暗色矢量图标">
</div>
颜色分别是: `#1660A5 (海蓝色)` `#545F70 (蓝灰色)` `#FFFFFF (单色明亮图标白色)` `#1A1A1A (单色暗色图标深黑色)` `#2f2f35 (文字颜色)`.
## 许可证
### 项目本身
本项目基于 AGPL-3.0 许可证开放源代码, 并有一个豁免本机 API 调用的附加条款, 较标准 AGPL-3.0 更宽松.
详见根目录下 [LICENSE](LICENSE) 文件.
### 第三方代码
项目在 `src/heurams/vendor/` 目录下嵌入或在其他位置直接使用了以下第三方代码或其衍生作品 (可能有修改):
#### SM.js
- 上游版本: commit `6e3bb4a` (2015年2月4日上游已停止维护)
- 引用方式: 将 coffeescript 重写为 python 并间接引用, 数学原理一致; 并对重写后代码进行逻辑, 性能与标准化 API 改进
- 位置: `src/heurams/kernel/algorithms/sm15m.py`
- 原项目: [SM.js](https://github.com/slaypni/SM-15)
- 原版权: Copyright (c) 2014 Kazuaki Tanida
- 原许可证: MIT License
本项目受益于他们无私且优秀的工作.
-39
View File
@@ -1,39 +0,0 @@
# [调试] 将更改保存到文件
persist_to_file = 1
# [调试] 覆写时间, 设为 -1 以禁用
daystamp_override = -1
timestamp_override = -1
# [调试] 一键通过
quick_pass = 1
# 对于每个项目的默认新记忆原子数量
scheduled_num = 8
# UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒
timezone_offset = +28800 # 中国标准时间 (UTC+8)
[puzzles] # 谜题默认配置
[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(仅用于支持 Android Termux), mpg123(TODO)
tts = "edgetts" # 可选项: edgetts
llm = "openai" # 可选项: openai
[providers.llm.openai] # 与 OpenAI 相容的语言模型接口服务设置
url = ""
key = ""
-23
View File
@@ -1,23 +0,0 @@
# Nucleon 是 HeurAMS 软件项目使用的基于 TOML 的专有源文件格式, 版本 5
# 建议使用的 MIME 类型: application/vnd.xyz.imwangzhiyu.heurams-nucleon.v5+toml
[__metadata__]
[__metadata__.attribution] # 元信息
desc = "带有宏支持的空白模板"
[__metadata__.annotation] # 键批注
[__metadata__.formation] # 文件配置
#delimiter = "/"
#tts_text = "eval:nucleon['content'].replace('/', '')"
[__metadata__.orbital.puzzles] # 谜题定义
# 我们称 "Recognition" 为 recognition 谜题的 alia
#"Recognition" = { __origin__ = "recognition", __hint__ = "", primary = "eval:nucleon['content']", secondary = ["eval:nucleon['keyword_note']", "eval:nucleon['note']"], top_dim = ["eval:nucleon['translation']"] }
#"SelectMeaning" = { __origin__ = "mcq", __hint__ = "eval:nucleon['content']", mapping = "eval:nucleon['keyword_note']", jammer = "eval:nucleon['keyword_note']", max_riddles_num = "eval:default['mcq']['max_riddles_num']", prefix = "选择正确项: " }
#"FillBlank" = { __origin__ = "cloze", __hint__ = "", text = "eval:nucleon['content']", delimiter = "eval:metadata['formation']['delimiter']", min_denominator = "eval:default['cloze']['min_denominator']"}
[__metadata__.orbital.schedule] # 内置的推荐学习方案
#quick_review = [["FillBlank", "1.0"], ["SelectMeaning", "0.5"], ["recognition", "1.0"]]
#recognition = [["Recognition", "1.0"]]
#final_review = [["FillBlank", "0.7"], ["SelectMeaning", "0.7"], ["recognition", "1.0"]]
+438
View File
@@ -0,0 +1,438 @@
## Overall Architecture Overview
```mermaid
graph TB
subgraph "User Interface Layer (TUI)"
TUI[Textual App]
Screens[Application Screens]
Widgets[Puzzle Widgets]
end
subgraph "Kernel Layer"
Reactor[Scheduling Reactor]
Algorithms[Algorithm Modules]
Particles[Data Models]
Puzzles[Puzzle Engine]
RepoLib[Repository System]
Auxiliary[Auxiliary Tools]
end
subgraph "Service Layer"
Config[Config Management ConfigDict]
Logger[Logging System]
Timer[Time Service]
Audio[Audio Service]
TTS[TTS Service]
Favorites[Favorites Management]
Attic[Persistence]
Hasher[Hash Service]
end
subgraph "Provider Layer"
AudioProv[Audio Provider]
TTSProv[TTS Provider]
LLMProv[LLM Provider]
end
subgraph "Data Layer"
RepoDir[TOML/JSON Repository Directory]
ConfigDir[TOML Config Directory]
Logs[Log Files]
end
TUI --> Screens
Screens --> Reactor
Screens --> RepoLib
Screens --> Widgets
Widgets --> Puzzles
Widgets --> Reactor
Reactor --> Algorithms
Reactor --> Particles
Reactor --> Puzzles
Particles --> RepoLib
RepoLib --> Config
RepoLib --> Auxiliary
Auxiliary --> Lict
Auxiliary --> Evalizer
TUI --> Config
TUI --> Logger
TUI --> Audio
TUI --> TTS
Config --> ConfigDir
Audio --> AudioProv
TTS --> TTSProv
Attic --> RepoDir
```
## Data Model
The project uses the physical particle metaphor as its core, decomposing memory units into three models:
### Nucleon - Content Layer
```
Nucleon(ident, payload, common)
```
- **Read-only** content container. Compiles and expands `payload` and `common` via `Evalizer` (an `eval()`-based template system).
- Contains `puzzles` field, defining which puzzle types this memory unit supports.
- Created by pairing `repo.payload` and `repo.typedef["common"]`.
- Once created, content cannot be modified (`__setitem__` raises `AttributeError`).
### Electron - State Layer
```
Electron(ident, algodata, algo_name)
```
- Wrapper for algorithm state data. Each Electron is bound to one algorithm (`algorithms[algo_name]`).
- `algodata` is a **reference** to the corresponding dictionary in the repository's `algodata.lict` — modifications are persisted immediately.
- Core methods: `activate()` (mark as activated), `revisor()` (rating iteration), `is_due()` (due check).
### Orbital - Strategy Layer
```
orbital = {
"schedule": ["quick_review", "recognition"],
"routes": {
"quick_review": [["MCQ", "1.0"], ["Cloze", "0.5"]],
"recognition": [["Recognition", "1.0"]],
}
}
```
- A plain dictionary defining the review phase flow and puzzle selection strategy within each phase.
- Each phase corresponds to a list of `(puzzle_type, probability_coefficient)` tuples; coefficients >1 indicate forced repetition count.
### Atom - Runtime Assembly
```
Atom(nucleon, electron, orbital)
```
- Runtime composition of the three, with `runtime` flags (`locked`, `min_rate`, `new_activation`).
- The basic unit operated on by the UI and scheduling layers.
- `revise()` calls `electron.revisor(min_rate)` when `locked` is true, performing the final rating iteration.
**Relationship Diagram**:
```mermaid
graph LR
subgraph "Persistent Storage"
Payload[(payload.toml)]
Common[(typedef.toml)]
Algodata[(algodata.json)]
Schedule[(schedule.toml)]
end
subgraph "Runtime Assembly"
Nucleon -->|Content| Atom
Electron -->|State| Atom
Orbital -->|Strategy| Atom
end
Payload -->|Repo| Nucleon
Common -->|Repo| Nucleon
Algodata -->|Repo| Electron
Schedule -->|Repo| Orbital
```
## Scheduling Reactor
The Scheduling Reactor is the core business process engine, designed as a three-layer nested finite state machine (based on the `transitions` library).
### State Enumeration
| State Machine | State | Description |
|---------------|-------|-------------|
| **RouterState** | `unsure` | Initial state, auto-advances |
| | `quick_review` | Quick review phase |
| | `recognition` | New memory recognition phase |
| | `final_review` | Final comprehensive review phase |
| | `finished` | Complete, execute rating |
| **ProcessionState** | `active` | In progress |
| | `finished` | Completed |
| **ExpanderState** | `exammode` | Exam mode (frontal answering) |
| | `retronly` | Retrospective mode (recognition only) |
### State Machine Nesting Structure
```mermaid
graph TB
subgraph "Router (Global Router)"
R[Router<br/>State: unsure→quick_review<br/>→recognition→final_review<br/>→finished]
P1[Procession Queue 1: Quick Review]
P2[Procession Queue 2: New Memories]
P3[Procession Queue 3: Final Review]
R --> P1
R --> P2
R --> P3
end
subgraph "Procession (Single-Phase Queue)"
P1 --> E1[Expander Atom A]
P1 --> E2[Expander Atom B]
P1 --> E3[Expander Atom C]
M{forward} --> |Done| Finish((FINISHED))
end
subgraph "Expander (Single Atom Expander)"
E1 --> S[(Orbital Strategy)]
S -->|Probability Expansion| PZ1[Puzzle 1: MCQ]
S -->|Probability Expansion| PZ2[Puzzle 2: Cloze]
PZ1 -->|Rating| RPT[report]
PZ2 -->|Rating| RPT
RPT -->|finish| RETRO[retronly mode]
end
```
### Data Flow Detail
```
Router.__init__(atoms)
├─ Split atoms into new/old
│ ├─ old_atoms → Procession(quick_review) "Initial review"
│ └─ new_atoms → Procession(recognition) "New memories"
└─ all atoms → Procession(final_review) "Final review"
└─ Procession.forward()
├─ cursor >= len(atoms) → finish()
└─ cursor < len(atoms) → next_atom
└─ Procession.get_expander()
└─ Expander(atom, route)
├─ Read orbital.routes[route_value]
├─ Probability expansion → self.puzzles_inf
├─ exammode → display puzzles sequentially
├─ report(rating) → record minimum rating
├─ forward() → next puzzle or finish → retronly
└─ retronly → display Recognition
└─ Atom.revise()
└─ Electron.revisor(min_rate)
└─ Algorithm.revisor(algodata, feedback)
```
Rating accumulation: The final rating for an atom across multiple puzzles takes the minimum rating (`min_rate`) across all puzzles, ensuring strict evaluation.
## Algorithm System
All algorithms inherit from `BaseAlgorithm`, implemented in a class-method style, registered via the `algorithms` dictionary.
| Algorithm | File | Status | Description |
|-----------|------|--------|-------------|
| **SM-2** | `sm2.py` | ✅ Complete | Classic SuperMemo 1987 algorithm |
| **NSP-0** | `nsp0.py` | ✅ Complete | Non-spaced filtering scheduler |
| **SM-15M** | `sm15m.py` | ✅ Complete | SM-15 ported from CoffeeScript |
| **FSRS** | `fsrs.py` | ✅ Partial | Optimizer not available |
| **Base** | `base.py` | ✅ Base class | Defines `AlgodataDict` structure and defaults |
Each algorithm provides the following class methods:
| Method | Function |
|--------|----------|
| `revisor(algodata, feedback, is_new_activation)` | Iterate memory data based on rating |
| `is_due(algodata)` | Check if due for review |
| `get_rating(algodata)` | Get rating information |
| `nextdate(algodata)` | Get next review timestamp |
| `check_integrity(algodata)` | Validate algodata data structure integrity |
### Algorithm Data Structure (AlgodataDict)
```python
{
"real_rept": int, # Actual review count
"rept": int, # Current repetition count
"interval": int, # Interval in days
"last_date": int, # Last review date
"next_date": int, # Next due date
"is_activated": int, # Whether activated (0/1)
"last_modify": float, # Last modification timestamp
}
```
## Repository System (Repo)
The repository is a directory of TOML/JSON files with no database dependency.
### Directory Structure
```
data/repo/<package_name>/
├── manifest.toml # Meta info: title, author, package, desc
├── typedef.toml # Common metadata, puzzle definitions, annotations
├── payload.toml # Memory items (key=ident)
├── algodata.json # Algorithm state (key=ident)
└── schedule.toml # Orbital/review strategy
```
### Repo Class Design
```mermaid
classDiagram
class Repo {
+dict schedule
+Lict payload
+dict manifest
+dict typedef
+Lict algodata
+Path source
+Lict nucleonic_data_lict
+dict orbitic_data
+Lict electronic_data_lict
+from_repodir(source) ~Repo
+from_dict(dictdata) ~Repo
+create_new_repo() ~Repo
+persist_to_repodir(save_list, source)
+export_to_dict() dict
}
```
- `payload` and `algodata` use `Lict` (list+dict hybrid container), supporting dual-mode access.
- `_generate_particles_data()` automatically converts payload data to `Nucleon`-required format during initialization.
- Default save list: `default_save_list = ["algodata"]`, only persists algorithm state.
## Lict Collection
`Lict` extends `MutableSequence`, maintaining both list and dictionary access:
```python
lict = Lict()
lict.append(("key1", value1)) # List append
lict["key1"] # Dict access
lict[0] # Index access
lict.keys() # All keys
lict.dicted_data # Plain dict export
```
Dirty sync mechanism: modifying the list automatically syncs to the dictionary; modifying the dictionary automatically syncs to the list. Used for dual-mode access to `payload` and `algodata`.
## Configuration System (ConfigDict)
`ConfigDict` extends `UserDict`, a **singleton** TOML lazy-loading configuration manager.
### Configuration Directory Convention
```
data/config/
├── _.toml # Top-level defaults (recursive merge)
├── interface/
│ ├── _.toml # Interface layer defaults
│ ├── global.toml
│ └── puzzles.toml
├── services/
│ ├── _.toml # Services layer defaults
│ ├── audio.toml
│ └── tts.toml
└── repo/
└── _.toml
```
- `_.toml` files = defaults for that directory level, merged into parent
- Suffixed files = lazy-loaded on demand
- Subdirectories = recursive sub-configuration
### Context Management
```python
from heurams.context import config_var, ConfigContext
# Global access
config = config_var.get()
algo = config["interface"]["global"]["algorithm"]
# Scope override
with ConfigContext(test_config):
... # Temporarily use test configuration
```
## Provider System (Providers)
Pluggable backend implementations, registered via dictionaries in `providers/__init__.py`.
| Category | Provider | Description |
|----------|----------|-------------|
| **TTS** | `edge_tts` | Microsoft Edge TTS (online) |
| | `basetts` | Stub base class (not implemented) |
| **Audio** | `playsound` | Cross-platform audio playback |
| | `termux` | Android Termux environment |
| **LLM** | `openai` | OpenAI compatible API (not fully implemented) |
Selection method: `provider` field in `services/*.toml`.
## Puzzle System (Puzzles)
The puzzle engine generates evaluation views during review phases:
| Puzzle | File | Description |
|--------|------|-------------|
| **MCQ** | `mcq.py` | Multiple Choice Questions |
| **Cloze** | `cloze.py` | Cloze Deletion |
| **Recognition** | `recognition.py` | Recognition identification |
| **Guess** | `guess.py` | Word meaning guessing |
| **Base** | `base.py` | Abstract base class |
Puzzles are expanded probabilistically by the Orbital strategy in the `Expander`. Each atom can generate multiple puzzles, and each puzzle is rated independently.
## Service Layer
| Service | File | Description |
|---------|------|-------------|
| **Config** | `config.py` | `ConfigDict(UserDict)` TOML lazy-loading singleton |
| **Logger** | `logger.py` | `get_logger(name)` → hierarchical logger (`heurams.*`) |
| **Timer** | `timer.py` | `get_daystamp()` / `get_timestamp()`, supports configurable override |
| **Audio** | `audio_service.py` | Audio playback, routes to configured audio provider |
| **TTS** | `tts_service.py` | Text-to-speech, routes to configured TTS provider |
| **Favorites** | `favorite_service.py` | JSON5-persisted favorites manager (singleton) |
| **Attic** | `attic.py` | Structured pickle persistence, supports `<DAYSTAMP>`/`<TIMESTAMP>` placeholders |
| **Hasher** | `hasher.py` | MD5 hashing |
| **Epath** | `epath.py` | Dot-notation nested dict access (`epath(dct, "a.b.c")`) |
| **TextProc** | `textproc.py` | `truncate()`, `domize()`, `undomize()` |
Logging system: Each module creates its own logger via `get_logger(__name__)`. Log files rotate at 10MB, max 5 backups, appended to `heurams.log`.
## Complete Review Flow
```mermaid
sequenceDiagram
participant User as User
participant UI as TUI
participant Router as Router
participant Procession as Procession
participant Expander as Expander
participant Atom as Atom
participant Electron as Electron
participant Algo as Algorithm
User->>UI: Start Review
UI->>Router: Router(atoms)
Router->>Procession: Create review queue
Router->>Procession: Create new memory queue
Router->>Procession: Create final review queue
Procession->>Expander: Expand current atom
Expander->>Expander: Parse orbital strategy
Expander-->>UI: Display puzzle
User->>UI: Rate (1-5)
UI->>Expander: report(rating)
Expander->>Expander: forward() next puzzle
Expander-->>UI: Next puzzle or retrospective
Expander->>Expander: finish() → retronly
Expander-->>UI: Recognition retrospective
User->>UI: Final rating
UI->>Atom: revise()
Atom->>Electron: revisor(min_rate)
Electron->>Algo: revisor(algodata, feedback)
Algo-->>Electron: Update algodata
Algo-->>Atom: Update interval, next_date
Procession->>Procession: forward() next atom
Procession-->>Router: Queue complete
Router->>Router: Switch phase
Router-->>UI: Complete (finished)
UI->>User: Show summary
```
+438
View File
@@ -0,0 +1,438 @@
## 整体架构概览
```mermaid
graph TB
subgraph "用户界面层 (TUI)"
TUI[Textual App]
Screens[应用屏幕]
Widgets[谜题组件]
end
subgraph "内核层 Kernel"
Reactor[调度反应器]
Algorithms[算法模块]
Particles[数据模型]
Puzzles[谜题引擎]
RepoLib[仓库系统]
Auxiliary[辅助工具]
end
subgraph "服务层"
Config[配置管理 ConfigDict]
Logger[日志系统]
Timer[时间服务]
Audio[音频服务]
TTS[TTS 服务]
Favorites[收藏管理]
Attic[持久化]
Hasher[哈希服务]
end
subgraph "提供者层"
AudioProv[音频提供者]
TTSProv[TTS 提供者]
LLMProv[LLM 提供者]
end
subgraph "数据层"
RepoDir[TOML/JSON 仓库目录]
ConfigDir[TOML 配置目录]
Logs[日志文件]
end
TUI --> Screens
Screens --> Reactor
Screens --> RepoLib
Screens --> Widgets
Widgets --> Puzzles
Widgets --> Reactor
Reactor --> Algorithms
Reactor --> Particles
Reactor --> Puzzles
Particles --> RepoLib
RepoLib --> Config
RepoLib --> Auxiliary
Auxiliary --> Lict
Auxiliary --> Evalizer
TUI --> Config
TUI --> Logger
TUI --> Audio
TUI --> TTS
Config --> ConfigDir
Audio --> AudioProv
TTS --> TTSProv
Attic --> RepoDir
```
## 数据模型
项目以物理粒子隐喻为核心, 将记忆单元拆解为三个模型:
### Nucleon (核子) - 内容层
```
Nucleon(ident, payload, common)
```
- **只读**内容容器. 通过 `Evalizer` (基于 `eval()` 的模板系统)对 payload 和 common 进行编译展开.
- 包含 `puzzles` 字段, 定义该记忆单元支持哪些谜题类型.
-`repo.payload``repo.typedef["common"]` 配对创建.
- 一旦创建, 内容不可修改 (`__setitem__` 抛出 `AttributeError`).
### Electron (电子) - 状态层
```
Electron(ident, algodata, algo_name)
```
- 算法状态数据的包装器. 每个 Electron 绑定一个算法 (`algorithms[algo_name]`).
- `algodata` 是到仓库 `algodata.lict` 中对应字典的**引用**, 修改即持久化.
- 核心方法:`activate()` (标记激活)、`revisor()` (评分迭代)、`is_due()` (到期判断).
### Orbital (轨道) - 策略层
```
orbital = {
"schedule": ["quick_review", "recognition"],
"routes": {
"quick_review": [["MCQ", "1.0"], ["Cloze", "0.5"]],
"recognition": [["Recognition", "1.0"]],
}
}
```
- 定义复习阶段流程和各阶段内谜题选择策略的纯字典.
- 每个阶段对应一组 `(谜题类型, 概率系数)` 元组列表, 概率系数 >1 的部分表示强制重复次数.
### Atom (原子) - 运行时组装
```
Atom(nucleon, electron, orbital)
```
- 三者的运行时组合, 附带 `runtime` 运行时标志 (`locked`, `min_rate`, `new_activation`).
- 是 UI 和调度层操作的基本单位.
- `revise()` 方法在 `locked` 为真时调用 `electron.revisor(min_rate)`, 执行最终评分迭代.
**关系图**
```mermaid
graph LR
subgraph "持久化存储"
Payload[(payload.toml)]
Common[(typedef.toml)]
Algodata[(algodata.json)]
Schedule[(schedule.toml)]
end
subgraph "运行时组装"
Nucleon -->|内容| Atom
Electron -->|状态| Atom
Orbital -->|策略| Atom
end
Payload -->|Repo| Nucleon
Common -->|Repo| Nucleon
Algodata -->|Repo| Electron
Schedule -->|Repo| Orbital
```
## 调度反应器 (Reactor)
调度反应器是核心业务流程引擎, 采用三层嵌套的有限状态机设计 (基于 `transitions` 库).
### 状态枚举定义
| 状态机 | 状态 | 说明 |
|--------|------|------|
| **RouterState** | `unsure` | 初始状态, 自动推进 |
| | `quick_review` | 快速复习阶段 |
| | `recognition` | 新记忆识别阶段 |
| | `final_review` | 最终总复习阶段 |
| | `finished` | 完成, 执行评分 |
| **ProcessionState** | `active` | 进行中 |
| | `finished` | 已完成 |
| **ExpanderState** | `exammode` | 考试模式 (正面答题) |
| | `retronly` | 回溯模式 (仅识别) |
### 状态机嵌套结构
```mermaid
graph TB
subgraph "Router (全局路由器)"
R[Router<br/>状态: unsure→quick_review<br/>→recognition→final_review<br/>→finished]
P1[Procession 队列1: 快速复习]
P2[Procession 队列2: 新记忆]
P3[Procession 队列3: 总复习]
R --> P1
R --> P2
R --> P3
end
subgraph "Procession (单阶段队列)"
P1 --> E1[Expander 原子A]
P1 --> E2[Expander 原子B]
P1 --> E3[Expander 原子C]
M{forward 推进} --> |完成| Finish((FINISHED))
end
subgraph "Expander (单原子展开器)"
E1 --> S[(轨道策略)]
S -->|概率展开| PZ1[谜题1: MCQ]
S -->|概率展开| PZ2[谜题2: Cloze]
PZ1 -->|评分| RPT[report]
PZ2 -->|评分| RPT
RPT -->|finish| RETRO[retronly 回溯模式]
end
```
### 数据流详解
```
Router.__init__(atoms)
├─ 新旧原子分流
│ ├─ old_atoms → Procession(quick_review) "初始复习"
│ └─ new_atoms → Procession(recognition) "新记忆"
└─ 所有原子 → Procession(final_review) "总体复习"
└─ Procession.forward()
├─ cursor >= len(atoms) → finish()
└─ cursor < len(atoms) → next_atom
└─ Procession.get_expander()
└─ Expander(atom, route)
├─ 读取 orbital.routes[route_value]
├─ 概率展开为谜题列表 self.puzzles_inf
├─ exammode → 依次展示谜题
├─ report(rating) → 记录最低评分
├─ forward() → 下一个谜题或 finish → retronly
└─ retronly → 展示 Recognition
└─ Atom.revise()
└─ Electron.revisor(min_rate)
└─ Algorithm.revisor(algodata, feedback)
```
评分累积机制: 原子在多谜题阶段的最终评分取所有谜题的最低评分 (`min_rate`), 确保严格评估.
## 算法系统
所有算法继承自 `BaseAlgorithm`, 以类方法的风格实现, 通过 `algorithms` 字典注册.
| 算法 | 文件 | 状态 | 说明 |
|------|------|------|------|
| **SM-2** | `sm2.py` | ✅ 完成 | 经典 SuperMemo 1987 算法 |
| **NSP-0** | `nsp0.py` | ✅ 完成 | 非间隔过滤调度器 |
| **SM-15M** | `sm15m.py` | ✅ 完成 | 从 CoffeeScript 移植的 SM-15 |
| **FSRS** | `fsrs.py` | ✅ 部分完成 | 优化器不可用 |
| **Base** | `base.py` | ✅ 基类 | 定义 `AlgodataDict` 结构和默认值 |
每个算法提供以下类方法:
| 方法 | 功能 |
|------|------|
| `revisor(algodata, feedback, is_new_activation)` | 根据评分迭代记忆数据 |
| `is_due(algodata)` | 判断是否到期复习 |
| `get_rating(algodata)` | 获取评分信息 |
| `nextdate(algodata)` | 获取下一次复习时间戳 |
| `check_integrity(algodata)` | 校验 algodata 数据结构完整性 |
### 算法数据结构 (AlgodataDict)
```python
{
"real_rept": int, # 实际复习次数
"rept": int, # 当前重复计数
"interval": int, # 间隔天数
"last_date": int, # 上次复习日期
"next_date": int, # 下次到期日期
"is_activated": int, # 是否已激活 (0/1)
"last_modify": float, # 最后修改时间戳
}
```
## 仓库系统 (Repo)
仓库为 TOML/JSON 文件目录, 无数据库依赖.
### 目录结构
```
data/repo/<package_name>/
├── manifest.toml # 元信息: title, author, package, desc
├── typedef.toml # 通用元数据、谜题定义、注解
├── payload.toml # 记忆条目 (key=ident)
├── algodata.json # 算法状态 (key=ident)
└── schedule.toml # 轨道/复习策略
```
### Repo 类设计
```mermaid
classDiagram
class Repo {
+dict schedule
+Lict payload
+dict manifest
+dict typedef
+Lict algodata
+Path source
+Lict nucleonic_data_lict
+dict orbitic_data
+Lict electronic_data_lict
+from_repodir(source) ~Repo
+from_dict(dictdata) ~Repo
+create_new_repo() ~Repo
+persist_to_repodir(save_list, source)
+export_to_dict() dict
}
```
- `payload``algodata` 使用 `Lict` (列表+字典混合容器), 支持双模式访问.
- `_generate_particles_data()` 在初始化时自动将 payload 数据转换为 `Nucleon` 所需的格式.
- 默认保存列表 `default_save_list = ["algodata"]`, 仅持久化算法状态.
## Lict 集合
`Lict` 继承 `MutableSequence`, 同时维护列表和字典访问:
```python
lict = Lict()
lict.append(("key1", value1)) # 列表追加
lict["key1"] # 字典访问
lict[0] # 索引访问
lict.keys() # 所有键
lict.dicted_data # 纯字典导出
```
脏同步机制:修改列表时自动同步字典, 修改字典时自动同步列表. 用于 `payload``algodata` 的双模式访问需求.
## 配置系统 (ConfigDict)
`ConfigDict` 继承 `UserDict`, 是**单例模式**的 TOML 懒加载配置管理器.
### 配置目录约定
```
data/config/
├── _.toml # 顶层默认值 (递归合并)
├── interface/
│ ├── _.toml # interface 层默认值
│ ├── global.toml
│ └── puzzles.toml
├── services/
│ ├── _.toml # services 层默认值
│ ├── audio.toml
│ └── tts.toml
└── repo/
└── _.toml
```
- `_.toml` 文件 = 该目录层级的默认值, 合并到父级
- 带后缀文件 = 按需懒加载
- 子目录 = 递归子配置
### 上下文管理
```python
from heurams.context import config_var, ConfigContext
# 全局访问
config = config_var.get()
algo = config["interface"]["global"]["algorithm"]
# 作用域覆盖
with ConfigContext(test_config):
... # 临时使用测试配置
```
## 提供者系统 (Providers)
可插拔的后端实现, 通过 `providers/__init__.py` 中的字典注册.
| 类别 | 提供者 | 说明 |
|------|--------|------|
| **TTS** | `edge_tts` | Microsoft Edge TTS (在线) |
| | `basetts` | 桩基类 (未实现) |
| **Audio** | `playsound` | 跨平台音频播放 |
| | `termux` | Android Termux 环境 |
| **LLM** | `openai` | OpenAI 兼容 API (未完整实现) |
选择方式:`services/*.toml` 中的 `provider` 字段.
## 谜题系统 (Puzzles)
谜题引擎用于在复习阶段生成评估视图:
| 谜题 | 文件 | 说明 |
|------|------|------|
| **MCQ** | `mcq.py` | 选择题 (Multiple Choice) |
| **Cloze** | `cloze.py` | 完形填空 (Cloze Deletion) |
| **Recognition** | `recognition.py` | 认读识别 |
| **Guess** | `guess.py` | 猜测词义 |
| **Base** | `base.py` | 抽象基类 |
谜题通过轨道策略 (Orbital)在 `Expander` 中按概率展开, 每个原子可产生多个谜题, 每个谜题独立评分.
## 服务层
| 服务 | 文件 | 说明 |
|------|------|------|
| **Config** | `config.py` | `ConfigDict(UserDict)` TOML 懒加载单例 |
| **Logger** | `logger.py` | `get_logger(name)` → 层级日志 (`heurams.*`) |
| **Timer** | `timer.py` | `get_daystamp()` / `get_timestamp()`, 支持可配置覆盖 |
| **Audio** | `audio_service.py` | 音频播放, 路由到配置的音频提供者 |
| **TTS** | `tts_service.py` | 文本转语音, 路由到配置的 TTS 提供者 |
| **Favorites** | `favorite_service.py` | JSON5 持久化的收藏管理器 (单例) |
| **Attic** | `attic.py` | 结构化 pickle 持久化, 支持 `<DAYSTAMP>`/`<TIMESTAMP>` 占位符 |
| **Hasher** | `hasher.py` | MD5 哈希 |
| **Epath** | `epath.py` | 点符号嵌套字典访问 (`epath(dct, "a.b.c")`) |
| **TextProc** | `textproc.py` | `truncate()`, `domize()`, `undomize()` |
日志系统:每个模块通过 `get_logger(__name__)` 创建自己的日志器, 日志文件 10MB 轮转, 最多 5 个备份, 追加到 `heurams.log`.
## 复习全流程
```mermaid
sequenceDiagram
participant User as 用户
participant UI as TUI
participant Router as Router
participant Procession as Procession
participant Expander as Expander
participant Atom as Atom
participant Electron as Electron
participant Algo as Algorithm
User->>UI: 开始复习
UI->>Router: Router(atoms)
Router->>Procession: 创建复习队列
Router->>Procession: 创建新记忆队列
Router->>Procession: 创建总复习队列
Procession->>Expander: 展开当前原子
Expander->>Expander: 解析轨道策略
Expander-->>UI: 展示谜题
User->>UI: 评分 (1-5)
UI->>Expander: report(rating)
Expander->>Expander: forward() 下一个谜题
Expander-->>UI: 下一个谜题或回溯
Expander->>Expander: finish() → retronly
Expander-->>UI: Recognition 回溯
User->>UI: 最终评分
UI->>Atom: revise()
Atom->>Electron: revisor(min_rate)
Electron->>Algo: revisor(algodata, feedback)
Algo-->>Electron: 更新 algodata
Algo-->>Atom: 更新 interval, next_date
Procession->>Procession: forward() 下一原子
Procession-->>Router: 队列完成
Router->>Router: 切换阶段
Router-->>UI: 完成 (finished)
UI->>User: 显示总结
```
+416
View File
@@ -0,0 +1,416 @@
# Frequently Asked Questions
## What is a terminal emulator?
A terminal emulator is an application that simulates and uses a terminal within a graphical desktop environment, e.g., KDE Konsole, GNOME Terminal, Windows Terminal, iTerm2, etc.
The old, modest little black window on older Windows (conhost.exe) is also a terminal emulator, but it has poor support for this software's basic user interface (and all modern terminal applications). We recommend using WezTerm (supports sixel) or Windows Terminal (does not support sixel) on Windows.
## Does the software support mobile devices?
The basic user interface (Textual TUI) works well in Android Termux.
Additionally, the KiriMemo frontend under development is based on the KDE Kirigami framework and will natively support Android and iOS.
## What is the difference between HeurAMS and Anki?
At a high level:
| Aspect | HeurAMS | Anki |
|--------|---------|------|
| Data format | Text files (TOML/JSON), human-readable | Proprietary compressed format with SQLite and resources (.apkg) |
| Review modes | Multi-phase flow + multiple puzzle types | Single/double-sided flashcards |
| Algorithm system | Modular, pluggable, multiple algorithm options | Built-in SM-2 / FSRS |
| Plugin ecosystem | Smaller, manifests as "capability extensions" like new algorithms or services | Larger, but uses unrestricted "monkey patching" |
| User base | Small | Large |
| Existing resource richness | Low | High |
| AI-assisted unit set/deck creation | Natively supported | Difficult |
| License | AGPL-3.0 with additional exemption clause | AGPL-3.0 |
## Is the software free?
Yes, completely free and open source. You can use all features without paying anything.
## How do I use this dark-looking interface?
First, if you just want a light color scheme, press the `d` key or click the "d theme" button to switch to a light interface. :)
Thanks to Microsoft's decades of "the command line is outdated" education, and the poor experience of `conhost.exe` and `cmd.exe`, it's completely normal to feel uncomfortable with a terminal user interface.
But in reality, even though it looks like an old computer screen, Textual and terminal standards are more modern than you might think.
### You can use the mouse
Modern terminal emulators (such as Windows Terminal, Konsole, iTerm2, WezTerm, etc.) support a feature called "Mouse Tracking". When Textual starts, it sends special instructions to the terminal to report mouse events.
So contrary to what you might expect, you can actually click buttons with your mouse, just like in regular software.
### You can also use the keyboard
- `Tab` key switches focus between different areas
- `Arrow keys` move up and down in lists
- `Enter` confirms a selection
- `q` goes back
- Key hints are displayed on screen, e.g., `[n] Navigator` means pressing `n` opens the navigator
### Touchscreen also works
On tablets or phones in Termux, you can touch or swipe to operate.
## How do I start the software?
First, ensure Python (3.12.13 recommended) is installed on your system along with the required HeurAMS components.
### Windows
Open "Command Prompt" or "PowerShell", enter the following command and press Enter, or save it as a shortcut:
```
python -m heurams.interface
```
### macOS
Open the "Terminal" application and enter the above command.
### Linux
Open your terminal emulator (usually Ctrl + Alt + T) and enter the above command.
If you find typing the command every time too cumbersome, you can create a desktop shortcut or script file. See online tutorials for details.
## How do I exit the software?
Press the `q` key to return to the main screen, then exit.
Your learning progress is saved automatically and will not be lost.
## Images are very pixelated and blurry, what should I do?
This means the image is being displayed in Halfcell compatibility mode.
The terminal emulator needs to support the sixel image protocol for high-quality image display. For terminals that don't support it, the software can only display images in low-quality compatibility mode.
- WezTerm (all major OS): Supported
- KDE Konsole: Supported
- GNOME Terminal: Not supported
- iTerm2 (macOS): Supported
- Windows Terminal: Not supported
- mintty (Windows): Supported
If your terminal doesn't support images, other software features are unaffected — only images in memorization content won't be displayed.
## Chinese characters display as garbled text or boxes?
This means your terminal's Chinese font is not set correctly. Please check:
1. You are not using terminals like getty or xterm that explicitly do not support non-ASCII fonts
2. In the terminal settings, choose a font that supports Chinese, such as "Noto Sans SC", "Microsoft YaHei", "Source Han Sans"
3. Ensure the terminal's character encoding is set to UTF-8 (usually the default)
## Where is my data stored? Will it be lost?
Data is stored in the `data/` folder under the software installation directory:
- You can open and modify files directly with a text editor
- Copy the `data/` folder to a USB drive or cloud storage for backup (regular backups recommended)
- Even if the software is uninstalled, as long as you keep the `data/` folder, all learning records will be preserved when you reinstall and copy it back
## How do I share my unit sets with friends?
Find the corresponding folder under `data/repo/`, copy the entire folder, and send it to your friend. They can place it in their own `data/repo/` directory to use it.
You can also export as a single text file or compressed archive and share via messaging apps, email, etc.
## I can't copy/paste content?
Generally, in a terminal:
- Copy: Ctrl+Shift+C
- Paste: Ctrl+Shift+V
This differs from regular software habits (because Ctrl+C means "interrupt process" in terminal semantics), but you'll get used to it quickly.
## The font is too small/large?
Adjust the "font size" option in your terminal emulator settings.
The software follows the terminal's font settings.
## Why does my interface look different from the screenshots?
The screenshots were taken using Konsole on KDE Plasma desktop, 80x25 character size, with Cascadia Code and Noto Sans SC fonts.
If your terminal size is larger, the interface will have more room. Using different fonts or operating systems may result in slight visual differences.
Functionality is identical.
## What do the ratings (1-5) mean? How should I rate?
We should note that we strongly discourage the Anki-like approach of having users directly self-rate their performance (implemented as `basic_puzzle` in our program, used primarily for algorithm testing).
We believe this approach is highly subjective and requires you to think about "What score am I?", "Am I being too optimistic?", "Am I scoring too low?", "What if I rate incorrectly?" — a series of questions that interrupt the memorization process and cause anxiety. This essentially shifts responsibility to the user and contradicts cognitive science principles.
Moreover, this approach is detrimental to academic research and experimentation, as user self-rated data is unreliable.
Therefore, HeurAMS frontends have built-in automatic rating systems based on user behavior analysis, i.e., "puzzles".
It automatically rates you based on the difficulty of the question and your answering behavior (including but not limited to correctness, number of operations undone, effective answering time).
However, if you or a unit set chooses to use `basic_puzzle`, or if you plan to implement your own automatic rating system, the meaning of scores is as follows:
| Score | Meaning | Description |
|-------|---------|-------------|
| 1 | Completely forgotten | Couldn't recall at all, as if never learned |
| 2 | Vague | Seems familiar, but couldn't answer |
| 3 | Some impression | Recalled after thinking for a while, not very certain |
| 4 | Relatively smooth | Could answer, but hesitated slightly |
| 5 | Very easy | Immediately recalled, no effort |
- Scoring **1-2**: The software considers you haven't mastered it and will schedule another review soon
- Scoring **3**: Normal mastery, review at the planned interval
- Scoring **4-5**: Well mastered, the next review interval will be extended
We suggest you don't overthink it, just score based on your first instinct — the software will automatically adjust the review pace based on your ratings.
Of course, we still recommend avoiding this approach and using other puzzle types for evaluation when possible.
You might think this method allows users to directly "intervene" with the algorithm, similar to Baicizhan's "斩" (chop) feature for skipping familiar content. In reality, you don't need to do that: we have built-in "quick pass/correct response" functionality, equivalent to directly selecting "5".
## Do I need to open the software every day? What if I don't study?
In theory, you don't need to open it every day. The software automatically records when each knowledge point is due for review next.
However, it is recommended to open the software daily to check your status.
Even if you skip studying for days or weeks:
- Knowledge points you've already learned won't disappear — they'll just need a few more reviews next time
- Learning records are safely stored in the `data/` folder and won't be lost
For best results, try to follow the software's review reminders; but it's okay to skip a few days when you're busy.
## Can I study multiple subjects simultaneously?
Yes.
Each subject or course can be made into an independent "unit set".
## I changed computers. How do I migrate data?
1. On the old computer, copy the entire `data/` folder to a USB drive or cloud storage
2. Install HeurAMS on the new computer
3. Overwrite the new computer's `data/` folder with the one from the USB drive
All learning records, configurations, and unit sets are fully migrated.
## I don't understand some terminology
| Term | Meaning |
|------|---------|
| **Unit Set** | Like a "book" or "course", containing a series of knowledge points |
| **Puzzle** | A question type to test you (MCQ, Cloze, etc.) |
| **Algorithm** | The "intelligent scheduler" that decides when to review which knowledge point |
| **Review Queue** | The list of knowledge points due for review today |
| **Activation** | Starting to learn a knowledge point for the first time |
| **Due** | A knowledge point is due for review |
## The software is stuck/unresponsive?
1. First, wait a moment
2. If that doesn't work, close the terminal window directly
3. Reopen the software
4. If you have time, please report the issue. We apologize for the inconvenience.
## Will using both Anki and HeurAMS cause conflicts?
No.
They are independent software programs with no data interference. You can gradually migrate content to HeurAMS, or use both together.
## Do I need to install Python?
Yes. HeurAMS is a Python-based software.
- Windows/macOS: Download and install from python.org
- Linux: Python is usually pre-installed
- Android: Install the Termux app, then install Python within Termux (run `pkg in python`)
If you see an error like "python is not recognized as an internal or external command", Python is not properly installed or added to your system PATH. Search for "Python installation tutorial" and follow the steps.
The recommended Python version for HeurAMS is 3.12.13.
## Is the software safe? Could it contain viruses?
HeurAMS is open source software. All code is publicly available for inspection and contains no viruses or backdoors.
It only reads and writes to its own `data/` folder and will not touch other files on your computer.
## The software shows an error with a lot of text I don't understand?
1. Copy the error message
2. First check if this page covers your issue
3. If not, upload it along with the software logs to issues, and we will handle it as soon as possible
## Can I pause mid-review? Will it restart from the beginning?
There is currently no save-state functionality, but we will add it soon.
## How do I see how much I've learned? Are there statistics?
The dashboard interface displays statistical information.
You can return to the dashboard anytime via the navigator.
## I feel the review is too fast/slow. Can I adjust it?
Yes. You can change the review pace by switching algorithms, adjusting algorithm parameters, or changing the number of memory units.
Relevant settings can be found in the settings interface.
## Can I change the interface color/theme?
Yes. The Textual framework provides various themes.
## I accidentally deleted something. Can I recover it?
This depends on whether your system has a recycle bin enabled. The software itself does not have an auto-backup function.
## Can I carry the software on a USB drive?
Yes. Copy the entire HeurAMS folder to a USB drive, install Python on any computer, and run it. Learning data in the `data/` directory will also be carried along.
## How do I turn off voice reading?
Find the TTS-related option in the settings interface and disable it.
## Where can I download unit sets made by others?
Currently, there is no official unit set marketplace.
However, as the community grows, users may share unit sets in the future. You can also share unit sets with friends.
## Can I export learning content for printing?
Yes.
The software supports exporting unit sets as a single text file, which you can open and print with any text editor. You can also copy content directly into software like Word.
## I want to start over from scratch. How do I reset?
Delete the `algodata.json` file in the corresponding unit set folder under `data/repo/` to reset all learning progress.
## How do I create my own unit set?
Create a new folder under `data/repo/` with the following files:
```
data/repo/my_pack/
├── manifest.toml # Meta info: title, author, etc.
├── typedef.toml # Common field definitions and puzzle configuration
├── payload.toml # Memory item content
├── algodata.json # Algorithm state data (can be empty)
└── schedule.toml # Review strategy configuration
```
You can also use the tools we provide to convert from CSV format, or use AI tools to generate unit sets.
## How do I switch algorithms?
Detailed instructions are available in the settings interface.
## How do I import from Anki?
There is currently no migration tool, as the design philosophies of the two software programs differ.
But keep an eye on the HeurStudio project — it will fundamentally solve content creation and migration issues. :)
## Why not Flutter?
Flutter is an excellent framework for building cross-platform graphical interfaces. One of HeurAMS's design goals is to keep the core library independent of any specific frontend.
However, Flutter is not as good as PyOtherSide when it comes to "integrating Python" — it can only communicate with the library through RPC standard components. Additionally, Flutter's desktop multi-window support has not been officially and stably supported, so we temporarily abandoned Flutter in favor of Kirigami.
Currently, we have prioritized the development of a Textual-based TUI frontend and a Kirigami-based native frontend. However, this does not preclude the possibility of a Flutter or other framework frontend in the future.
If you are interested in developing a Flutter frontend, please refer to the [Contributing Guide](../CONTRIBUTING.md#new-user-interface-frontends).
## Does the software need internet access?
Core review functionality works completely offline. The following features require internet:
- Edge TTS provider for Text-to-Speech
- LLM providers (e.g., OpenAI compatible API)
- Downloading unit sets from remote repositories
## What is the "local API call exemption" in the license?
In short, if you use HeurAMS in your own program through local inter-process API communication (such as RPC calls on the same host) without network forwarding, your program is not bound by the AGPL-3.0 license.
This additional clause is designed to encourage the development of third-party frontends and tools.
Therefore, HeurAMS's license is effectively more permissive than the original AGPL-3.0.
## What is the difference between HeurAMS and Baicizhan?
At a high level:
| Aspect | HeurAMS | Baicizhan |
|--------|---------|-----------|
| Usage scenario | Computer/Terminal | Mobile App |
| Learning content | Any knowledge, unlimited languages and subjects | English vocabulary |
| Memory strategy | Multiple algorithms, customizable review flow | Fixed algorithm, not adjustable |
| Quiz methods | Multiple puzzle types: MCQ, Cloze, Recognition | Picture-word matching, audio-meaning selection, etc. |
| Content creation | Self-created or imported, completely free | Official word books only |
| Cost | Completely free, no in-app purchases | Free memorization + paid courses |
| Data ownership | Data in your hands, text files | Data cannot be extracted |
| Offline use | Core functionality fully offline | Some features require internet |
| Learning statistics | Basic statistics | Check-in/Leaderboard/Social |
## Baicizhan has picture association memory. Does HeurAMS have it too?
Yes.
If your terminal supports image display (such as Konsole or WezTerm), unit sets can include images that will be displayed during review.
However, you need to add images to the unit set yourself.
## Baicizhan has check-ins and leaderboards. Does HeurAMS have them?
Currently no.
HeurAMS does not have check-ins, leaderboards, or social features, and does not collect your learning data.
## Baicizhan has ready-made word books. Where can I find content for HeurAMS?
Baicizhan's courses are officially made. HeurAMS content needs to be created by yourself or obtained from the community.
See "How do I create my own unit set?" for details.
## Baicizhan is convenient to use on mobile. Can HeurAMS be used on mobile?
Yes, but at this stage it requires some setup.
Android users can install Termux to run HeurAMS's basic user interface.
Additionally, the KiriMemo frontend under development will natively support Android and iOS, requiring no setup from users.
## Baicizhan helps with vocabulary. What else can HeurAMS be used for?
Any knowledge that requires memorization: foreign language vocabulary, medical terminology, legal texts, historical dates, chemical equations, programming syntax, musical notation...
Unit set content is entirely defined by you.
## Baicizhan is effective for learning English. Will HeurAMS be less effective?
Considering that Baicizhan's algorithms and vocabulary database are effectively closed-source, we have no way of knowing the algorithm source.
However, HeurAMS's architecture design ensures that once a unit set is created, it can be at least as effective as Baicizhan — or even better.
HeurAMS's spaced repetition algorithms are based on the same cognitive science principles, and the algorithms are transparent and adjustable, allowing you to freely choose the scheduling strategy that best suits you.
## How do I participate in the project?
See the [Contributing Guide](../CONTRIBUTING.md).
Even if you are not a developer, you can participate by writing documentation, creating memory unit sets, translating the interface, answering questions, and more.
+416
View File
@@ -0,0 +1,416 @@
# 常见问题
## 什么是终端模拟器?
终端模拟器是在图形桌面环境中模拟并使用终端的应用程序, 例如 KDE Konsole, GNOME Terminal, Windows Terminal, iTerm2 等.
较旧 Windows 的那个很寒酸的小黑窗口也是终端模拟器(conhost.exe), 但它对此软件基本用户界面(以及一切现代终端应用)支持不佳, 建议在 Windows 平台使用 WezTerm (支持 sixel) 或 Windows Terminal (不支持 sixel).
## 软件支持移动设备吗?
基本用户界面 (Textual TUI) 可在 Android Termux 中良好运行.
此外, 正在开发的 KiriMemo 前端基于 KDE Kirigami 框架, 将原生支持 Android 和 iOS.
## HeurAMS 和 Anki 有什么区别?
大体地说:
| 方面 | HeurAMS | Anki |
|------|---------|------|
| 数据格式 | 文本文件 (TOML/JSON), 人类可读 | SQLite 和资源文件组成的专有压缩格式 (.apkg) |
| 复习模式 | 多阶段流程 + 多种谜题类型 | 单面/双面闪卡 |
| 算法系统 | 模块化, 可插拔, 多种算法可选 | 内置 SM-2 / FSRS |
| 插件生态 | 较少, 体现于类似微内核架构的"能力扩展", 例如新算法或新服务 | 多, 但为不受限的"猴子补丁" |
| 用户基数 | 少 | 多 |
| 现有资源丰富度 | 少 | 多 |
| AI 辅助产生单元集/牌组 | 原生支持 | 困难 |
| 协议 | AGPL-3.0, 有一个附加豁免条款 | AGPL-3.0 |
## 软件是免费的吗?
是的, 完全免费, 且开源. 您无需支付任何费用即可使用全部功能.
## 黑乎乎的这个界面我怎么用?
首先, 如果您只是想要一个亮色模式, 可以直接按下 `d` 键或点击 "d 主题" 按钮, 这会让您的界面变得白乎乎的(
得益于微软几十年对用户进行的"命令行即落后"教育, 以及 `conhost.exe``cmd.exe` 的糟糕体验, 您对终端用户界面感到不适应是完全正常的.
但实际上, 虽然看起来像老式电脑屏幕, Textual 和终端标准其实比您想象得要现代一些.
### 可以用鼠标
现代终端模拟器 (如 Windows Terminal、Konsole、iTerm2、WezTerm 等) 支持一个叫做 "鼠标跟踪" (Mouse Tracking) 的功能, 而 Textual 启动时会发送特殊指令给终端让它报告鼠标事件.
所以可能和您的想象不同, 您事实上可以直接用鼠标点击按钮, 就像使用普通软件一样.
### 也可以用键盘
- `Tab` 键在不同区域之间切换焦点
- `方向键` 在列表中上下移动
- `Enter` 确认选择
- `q` 返回
- 屏幕上会有按键提示, 例如 `[n] 导航器` 表示按 `n` 键打开导航器
### 触屏也可以
在平板或手机 Termux 中, 您可以触摸或者滑动屏幕操作.
## 我怎么启动这个软件?
首先需要确保系统中安装了 Python (推荐 3.12.13 版本) 并安装了 HeurAMS 的所需组件.
### Windows
打开"命令提示符"或"PowerShell", 输入以下命令后按回车, 或者把这玩意另存为快捷方式:
```
python -m heurams.interface
```
### macOS
打开"终端"应用程序, 输入以上命令.
### Linux
打开您的终端模拟器 (一般是按 Ctrl + Alt + T), 输入以上命令.
如果您觉得每次输入命令太麻烦, 可以创建一个桌面快捷方式或脚本文件, 详见网上的相关教程.
## 我怎么退出软件?
按键盘上的 `q` 键返回主界面后退出.
您的学习进度会自动保存, 不会丢失.
## 图片像素很大, 模糊得像马赛克一样怎么办?
这说明图像以 Halfcell 兼容模式显示
终端模拟器需要支持 sixel 图像协议才能高清地显示图片, 对于不支持的图片, 软件只能以低清的兼容模式显示.
- WezTerm (适用于几乎所有操作系统): 支持
- KDE Konsole: 支持
- GNOME Terminal: 不支持
- iTerm2 (macOS): 支持
- Windows Terminal: 不支持
- mintty (Windows): 支持
如果您的终端不支持图片, 软件的其他功能不受影响, 只是记忆内容中的图片无法显示.
## 中文显示成乱码或方框怎么办?
这说明您没有正确设置终端中文字体. 请检查:
1. 您没有使用 getty 和 xterm 这种明确不支持非 ASCII 字体的终端
2. 终端设置中的字体选项, 选择一款支持中文的字体, 例如 "Noto Sans SC", "微软雅黑", "Source Han Sans"
3. 确保终端的字符编码设置为 UTF-8 (通常是默认值)
## 我的数据存哪里了? 会不会丢?
数据存储在软件安装目录下的 `data/` 文件夹中:
- 您可以用记事本直接打开查看和修改
- 复制粘贴 `data/` 文件夹到 U 盘或网盘即可完成备份 (推荐定期备份)
- 即使软件卸载了, 只要保留 `data/` 文件夹, 重新安装后复制回去, 所有学习记录都在
## 怎么跟朋友分享我的单元集?
找到 `data/repo/` 下的对应文件夹, 复制整个文件夹发给朋友即可. 对方把它放到自己的 `data/repo/` 目录下就能用.
您也可以导出为单一文本文件或压缩包, 通过微信、QQ、邮件等方式分享.
## 我复制粘贴不了内容?
一般来说, 在终端中:
- 复制: Ctrl+Shift+C
- 粘贴: Ctrl+Shift+V
这和普通软件的操作习惯不太一样(因为 Ctrl + C 在终端中的语义是 "中断进程"), 但稍加适应即可.
## 字体太小/太大怎么办?
在您的终端模拟器设置中找到"字体大小"选项进行调整.
软件会跟随终端的字体设置.
## 为什么我的界面和截图不一样?
截图使用的是 KDE Plasma 桌面上的 Konsole, 80x25 字符尺寸, Cascadia Code 和 Noto Sans SC 字体.
如果您的终端尺寸更大, 界面会更宽裕; 如果使用不同字体或者不同操作系统, 视觉效果会略有差异.
功能上完全一致.
## 评分 (1-5) 是什么意思? 我该怎么打分?
需要说明的是, 我们非常不鼓励这种类似 Anki, 让用户自己直接给自己评分的单元集设计(在我们的程序中, 这种方式的实现被称为 `basic_puzzle`, 基本只用于算法测试).
因为我们认为这种方式非常主观, 而且还需要您思考"我是多少分""我是不是乐观了""我是不是分低了""我要是把分评错了怎么办"这一系列打断记忆进程且令人焦虑的问题, 这本质上是把责任推给用户, 并且违背了认知科学原理.
并且这种方式于学术研究与实验不利, 用户自评分产生的数据是不可靠的.
因此 HeurAMS 的前端内建了基于用户行为分析的自动评分系统, 也就是"谜题".
它会根据题目本身难度和您的答题行为(包括但不限于正确性, 操作回退次数, 有效答题时间)并自动为您评分.
但如果您或者某个单元集选择使用 `basic_puzzle`, 或者打算自己实现自动评分系统, 分数含义如下:
| 分数 | 含义 | 说明 |
|------|------|------|
| 1 | 完全忘了 | 一点都没想起来, 跟没学过一样 |
| 2 | 模糊 | 好像见过, 但答不上来 |
| 3 | 有点印象 | 想了一会儿才答对, 不太确定 |
| 4 | 比较顺利 | 能答对, 但稍微犹豫了一下 |
| 5 | 非常轻松 | 立刻反应过来, 毫不费力 |
-**1-2 分**: 软件会认为您还没掌握, 短期内会再次安排复习
-**3 分**: 正常掌握, 按计划间隔复习
-**4-5 分**: 掌握得很好, 下次复习间隔会拉长
我们建议您不要纠结, 凭第一感觉打分就好, 软件会根据您的评分自动调整复习节奏.
当然我们还是建议尽量避免这种方式并尽量使用其他谜题评测.
您可能认为那种方式可以让用户直接"干预"算法, 类似百词斩的"斩"功能用于跳过已经熟悉的内容, 实际上您并不需要那样做: 我们内建了"快速通过/正确应答"功能, 等同于直接选择"5".
## 我每天都要打开软件吗? 不学会怎样?
理论上不需要每天打开. 软件会自动记录每个知识点下次该复习的时间.
但建议您每天打开软件看下状态.
即使隔了几天甚至几周没学:
- 已经记住的知识点不会消失, 只是下次复习时会多复习几次
- 学习记录完好保存在 `data/` 文件夹里, 不会丢失
建议尽量按软件的提醒复习, 效果最好; 但忙的时候跳过几天也没关系.
## 能同时学多个科目吗?
可以.
每个科目或课程可以做成独立的"单元集".
## 我换电脑了, 怎么迁移数据?
1. 在旧电脑上复制整个 `data/` 文件夹到 U 盘或网盘
2. 在新电脑上安装好 HeurAMS
3. 用 U 盘里的 `data/` 文件夹覆盖新电脑上的 `data/` 文件夹
所有学习记录、配置、单元集全部迁移完毕.
## 一些术语听不懂
| 术语 | 意思 |
|------|--------|
| **单元集** | 相当于"一本书"或"一门课", 包含一系列知识点 |
| **谜题** | 测试您的题目类型 (选择题、填空题等) |
| **算法** | 决定什么时候该复习哪个知识点的"智能排课表" |
| **复习队列** | 今天需要复习的知识点列表 |
| **激活** | 第一次开始学习某个知识点 |
| **到期** | 到了该复习这个知识点的时间了 |
## 软件卡住了/没反应怎么办?
1. 建议先等片刻
2. 如果不行, 直接关闭终端窗口
3. 重新打开软件
4. 如果您有时间的话, 欢迎报告问题, 我们为此深表歉意
## 同时用 Anki 和 HeurAMS 会冲突吗?
不会.
两者是独立的软件, 数据互不影响. 您可以逐步将内容迁移到 HeurAMS, 也可以两个一起用.
## 我需要安装 Python 吗?
需要的, HeurAMS 是基于 Python 的软件.
- Windows/macOS: 从 python.org 下载安装即可
- Linux: 系统通常已自带 Python
- Android: 安装 Termux 应用, 然后在 Termux 中安装 Python (运行 `pkg in python`)
如果看到"python 不是内部或外部命令"的提示, 说明 Python 没有正确安装或添加到系统路径, 搜索"Python 安装教程"按步骤操作即可.
HeurAMS 建议的 Python 版本是 3.12.13.
## 软件安全吗? 会不会有病毒?
HeurAMS 是开源软件, 所有代码公开可查阅, 不会有病毒或后门.
它只读写自己的 `data/` 文件夹, 不会动您电脑上的其他文件.
## 软件报错, 出现一堆我看不懂的英文怎么办?
1. 把错误信息复制下来
2. 先找一下这个页面有没有收录您遇到的问题
3. 如果没有, 可以和软件日志一起上传到 issues, 我们会尽快处理
## 复习到一半可以暂停吗? 下次会从头开始吗?
暂时没有保存中间状态的功能, 但我们很快会添加.
## 怎么看我学了多少? 有统计吗?
仪表盘界面会显示统计信息.
您可以通过导航器随时回到仪表盘查看.
## 我觉得复习太快/太慢了, 能调吗?
可以. 您可以通过切换算法或调整算法参数或改变记忆单元数来改变复习节奏.
在设置界面可以找到相关设置.
## 能换界面颜色/主题吗?
能的, Textual 框架提供了多种主题.
## 我不小心删了东西, 能恢复吗?
这取决于您系统是否启用回收站, 软件本身没有自动备份功能.
## 能把软件放 U 盘随身带吗?
可以. 将整个 HeurAMS 文件夹复制到 U 盘, 在任意电脑上安装 Python 后运行即可. `data/` 目录下的学习数据也会一同携带.
## 怎么关掉语音朗读?
在设置界面中找到 TTS 相关选项, 将其关闭即可.
## 哪里可以下载别人做好的单元集?
目前项目还没有官方的单元集市场.
但随着社区发展, 未来可能会有用户分享的单元集, 您也可以和朋友互相分享.
## 我能把学习内容导出打印吗?
可以.
软件本身支持将单元集导出为单一文本文件, 您可以用任何文本编辑器打开并打印. 也可以直接复制内容到 Word 等软件.
## 我想从头重新学, 怎么重置?
删除 `data/repo/` 下对应单元集文件夹中的 `algodata.json` 文件即可重置所有学习进度.
## 如何创建自己的单元集?
`data/repo/` 目录下创建一个新文件夹, 包含以下文件即可:
```
data/repo/my_pack/
├── manifest.toml # 元信息: title, author 等
├── typedef.toml # 通用字段定义和谜题配置
├── payload.toml # 记忆条目内容
├── algodata.json # 算法状态数据 (可留空)
└── schedule.toml # 复习策略配置
```
您也可以使用我们提供的工具从 CSV 等格式转换, 或利用 AI 工具生成.
## 如何切换算法?
设置界面中有详细的说明.
## 如何从 Anki 导入?
暂时没有迁移工具, 因为两个软件的设计思路不同.
但欢迎关注 HeurStudio 项目, 它能从根本上解决内容创建与迁移问题 :)
## 为什么不用 Flutter?
Flutter 是构建跨平台图形界面的优秀框架, HeurAMS 的设计目标之一是保持核心程序库独立于特定前端.
但 Flutter 在 "集成 python" 方面不如 PyOtherSide, 只能通过 RPC 标准件和程序库通讯, 并且 Flutter 的桌面多窗口一直以来没有被官方稳定支持, 所以我们暂时放弃了 Flutter 而选择了 Kirigami.
当前我们优先开发了基于 Textual 的 TUI 前端和基于 Kirigami 的原生前端, 但这不排除未来出现 Flutter 或其他框架前端的可能性.
如果您有兴趣开发 Flutter 前端, 欢迎参考[贡献指南](../CONTRIBUTING_zh.md#%E6%96%B0%E7%9A%84%E7%94%A8%E6%88%B7%E7%95%8C%E9%9D%A2%E5%89%8D%E7%AB%AF).
## 软件需要联网吗?
核心复习功能完全离线可用. 以下功能需要联网:
- 文本转语音 (TTS) 的 Edge TTS 提供者
- LLM 提供者 (如 OpenAI 兼容 API)
- 从远程仓库下载单元集
## 许可证中的"本机 API 调用豁免"是什么?
简言之, 如果您在自己的程序中通过本地进程间 API 方式的通信 (如同一主机上的 RPC 调用) 使用 HeurAMS, 而无需通过网络转发, 则您的程序不受 AGPL-3.0 许可证的约束.
这项附加条款旨在鼓励第三方前端和工具的开发.
所以 HeurAMS 的许可证实质上是比原始的 AGPL-3.0 松一点的.
## HeurAMS 和百词斩有什么区别?
大体地说:
| 方面 | HeurAMS | 百词斩 |
|------|---------|--------|
| 使用场景 | 电脑/终端 | 手机 App |
| 学习内容 | 任何知识, 不限语言和科目 | 英语单词 |
| 记忆策略 | 多算法可选, 可自定义复习流程 | 固定算法, 不可调 |
| 测验方式 | 选择题/填空题/识别题等多种谜题 | 看图选词/听音选义等多种谜题 |
| 内容创建 | 自己创建或导入, 完全自由 | 仅官方提供词书 |
| 费用 | 完全免费, 无内购 | 免费记忆功能 + 付费课程 |
| 数据所有权 | 数据在您自己手上, 文本文件 | 数据不可被提取 |
| 离线使用 | 核心功能完全离线 | 部分功能需联网 |
| 学习统计 | 基础统计 | 打卡/排行榜/社交 |
## 百词斩有图片联想记忆, HeurAMS 也有吗?
支持.
如果您的终端支持图片显示 (如 Konsole 或者 WezTerm), 单元集中可以包含图片, 复习时会直接展示.
但图片需要您自己放入单元集.
## 百词斩有打卡和排行榜, HeurAMS 有吗?
目前没有.
HeurAMS 不设打卡、排行榜或社交功能, 也不向任何人收集您的学习数据.
## 百词斩有现成的词书, HeurAMS 去哪找内容?
百词斩的课程是官方制作好的, HeurAMS 的内容需要您自己创建或从社区获取.
详见"如何创建自己的单元集?".
## 百词斩在手机上用很方便, HeurAMS 能在手机用吗?
可以, 但现阶段需要您"折腾"一下.
Android 手机安装 Termux 后可运行 HeurAMS 的基本用户界面.
此外, 正在开发的 KiriMemo 前端将原生支持 Android 和 iOS, 这就不需要用户去折腾了.
## 百词斩能背单词, HeurAMS 还能学什么?
任何需要记忆的知识都可以: 外语单词、医学名词、法律条文、历史年代、化学方程式、编程语法、乐谱符号...
单元集的内容完全由您自己定义.
## 百词斩学英语效果很好, 换成 HeurAMS 会不会效果差?
考虑到百词斩的算法和词库是事实上闭源的, 我们无从得知算法来源.
但 HeurAMS 的架构设计可保证单元集制成后效果不比百词斩差, 甚至优于百词斩.
HeurAMS 的间隔重复算法基于相同的认知科学原理, 且算法透明可调, 您可以自由选择最适合自己的调度策略.
## 如何参与项目?
详见[贡献指南](../CONTRIBUTING_zh.md).
即使不是开发者, 您也可以通过编写文档、制作记忆单元集、翻译界面、答疑等方式参与.
+83
View File
@@ -0,0 +1,83 @@
## Features
### Spaced Repetition Scheduler
> Numerous publications have extensively discussed the effects of different repetition intervals on learning outcomes. In particular, the spacing effect is considered a universal phenomenon. The spacing effect refers to the fact that learning performance improves when repetitions are distributed/spaced rather than massed. Therefore, it has been proposed that the optimal repetition interval for learning is the **longest interval that does not lead to forgetting**.
- The software works out of the box, using the default `SM-2` algorithm for learning without additional configuration
- The algorithm module is a first-class citizen in the kernel (`heurams.kernel`), which natively supports pluggable algorithms of various types
- No complicated plugins required — algorithms can be quickly switched and tuned per unit set, enabling researchers to easily modify algorithm modules for convenient research and testing
- Defaults to the `SM-2` simple spaced repetition algorithm, also used as the default flashcard scheduler for Anki
- Also includes `NSP-0` non-spaced filtering algorithm for rapid content screening, `FSRS` advanced spaced repetition algorithm as a more efficient scheduler, and `SM-15M` (ported from sm.js project) complex spaced repetition algorithm (reverse-engineered)
- Algorithm modules can tag memorization items, dynamically plan memory interval schedules for each unit, and track memory feedback data to optimize long-term retention and stability
- Thanks to the modular architecture and unit set structure design, the same unit set can coexist and interoperate with any algorithm — extremely friendly for researchers and users exploring/experimenting with efficient methods
### Multimodal Learning Process
Unlike Anki's SQLite `.apkg` packages, we insist on using human-readable folder-organized unit sets, which brings several benefits:
- **Human-readable**: You can freely modify memorization payload data with any tool, even a simple text editor, without opening the software
- **Metadata configuration**: Extremely flexible configuration — you can freely combine, remix, or even create new content
- **Quiz, algorithm, and knowledge isolation**: A piece of knowledge is no longer a single flashcard; it can not only be scheduled with different algorithms but also tested with multiple parallel puzzle types, greatly enhancing learning effectiveness and richness. As a learner, you don't need to worry about complexity — just download a unit set from the cloud and use these features out of the box!
- **Multimodal learning**
- The software integrates Text-to-Speech (TTS), audio, and LLM modules — all pluggable, extensible, and driver-switchable, creating great content richness
- Built-in puzzle types include Multiple Choice Questions (MCQ), Cloze Deletion, and Recognition, all applicable to the same unit or selectively enabled
- Natively supports dynamic content generation with macro-driven template systems that generate knowledge point explanations based on context or even LLMs
- In the era when SuperMemo series dominated spaced repetition research, Wozniak already stated, "If you cannot understand knowledge, you don't need to memorize it." Today, we still believe understanding is the foundation of memorization
- **Cloud sync and sharing optimized**:
- Since memory data and unit set files are text files, fast incremental sync is possible without uploading all files, and the design natively supports version control
- If you want to share a single file, the software supports exporting as a compressed package or merging into a single text file for sharing on platforms like pastebin
- **Performance**: Thanks to the modern, chunked file organization structure, HeurAMS achieves agile and low-footprint user experience using only Python while maintaining high flexibility
- **AI-assisted friendly**: Imagine you have some `.apkg` decks or a large amount of textbook content — you can conveniently and efficiently use AI tools to create HeurAMS-compatible unit sets
### Built-in Practical User Interface
Although not the only frontend, the responsive Textual framework-based built-in terminal user interface has unique advantages in many scenarios:
- Cross-platform, with touch/mouse/keyboard multi-operation modes
- Compatible with almost all modern terminal emulators
- High-quality image display on terminal emulators that [support the sixel protocol](https://www.arewesixelyet.com/)
- Low-resolution compatibility display mode for terminals that don't support sixel
- Deployable as a service via textual-web, usable from any browser
- Clean, intuitive, keyboard-friendly, full-featured, and efficient UI design
- Easy to embed: can run in getty/kmscon without any desktop graphics service
- Low resource footprint, smooth operation
- Convenient for testing and debugging the library
View [Screenshots](SCREENSHOTS.md).
## Package Dependency Groups
Since some dependencies are only needed by a few features, we split optional dependencies into fine-grained groups. Here are the dependency groups:
| Dependency Group | Included Modules | Description |
|------------------|------------------|-------------|
| Build System | hatchling | Build-time installation |
| Minimal | tabulate, toml, transitions, click | Core driver libraries, always required |
| interface | textual | Basic user interface dependencies |
| algo-fsrs | fsrs | FSRS algorithm module |
| tts-edgetts | edge-tts | Microsoft Text-to-Speech |
| llm | llms-py | API calls |
| audio-playsound | playsound3 | General audio module |
| dev | zmq, pytest, pytest-cov | Development, debugging, and testing tools |
| basic | [tts-edgetts], [llm], [algo-fsrs | Lighter dependency group for user experience (recommended) |
| all | All of the above | Complete installation group |
## About This Repository
This repository is the Python implementation of the HeurAMS core library.
It contains data models and framework, and includes a built-in frontend based on the Textual framework (interface submodule).
Besides learning through the built-in frontend, developers can import the `heurams` library in a Python environment or communicate with `heurams` library instances via `RPC`, using the framework to build other auxiliary memory frontends or applications.
All repositories of the project group:
| Project Name | Status | Description | Package Name | Tech Stack | Target Platform |
| :--- | :--- | :--- | :--- | :--- | :--- |
| HeurAMS | In Development<br/>Prototype Usable | General core library with basic UI | `heurams` | Python | Standard Python Environment |
| KiriMemo | In Development<br/>Prototype Usable | Modern cross-platform frontend based on KDE technology | `org.kde.kirimemo` | C++, Qt6, Kirigami, PyOtherSide | Desktop & Mobile |
| ArkMemo | In Development | Modern cross-platform frontend based on ArkUI | `top.pluv27.arkmemo` | ArkTS, ArkUI | Mobile Devices |
| HeurStudio | Planned | AI-assisted single-unit set advanced creation and editing tool | `top.pluv27.heurstudio` | C++, Qt6, Kirigami, PyOtherSide | Desktop |
| HeurSync | In Development | User data sync server<br/>with Web frontend and leaderboard | `heursync` | Go, SQL | Web & Server |
| HeurRepo | In Development | Unit set document source server<br/>and sharing platform | `heurrepo` | Go, SQL | Web & Server |
Although the last three might sound a bit ambitious, our roadmap is clear.
+84
View File
@@ -0,0 +1,84 @@
## 特性
### 间隔重复调度器
> 许多出版物都广泛讨论了不同重复间隔对学习效果的影响. 特别是, 间隔效应被认为是一种普遍现象. 间隔效应是指, 如果重复的间隔是分散/稀疏的, 而不是集中重复, 那么学习任务的表现会更好. 因此, 有观点提出, 学习中使用的最佳重复间隔是**最长的, 但不会导致遗忘的间隔**.
- 软件开箱即用, 无需多加配置即可使用默认的 `SM-2` 算法进行学习
- 算法模块是 "潜进" 内核 (heurams.kernel) 中的一等公民, 内核天然支持插拔各型算法
- 无需安装繁杂的插件即可分单元集完成算法快速切换与调优, 研究者可以方便地修改算法模块以便捷地进行研究与测试
- 默认使用 `SM-2` 简单间隔重复算法, 此算法亦用作 `Anki` 闪卡记忆软件的默认闪卡调度器
- 还内置了 `NSP-0` 筛选用非间隔重复算法以便快速筛选记忆内容, `FSRS` 先进间隔重复算法作为效率更高的调度器, 与 `SM-15M (移植自 sm.js 项目)` 复杂间隔重复算法(逆向工程)
- 算法模块可以标记记忆项目, 也可以动态规划每个记忆单元的记忆间隔时间表, 动态跟踪记忆反馈数据, 以优化长期记忆保留率与稳定性
- 得益于项目的模块化架构与单元集结构设计, 同一个单元集可以与任意种算法共存并互通, 这对研究者及想探索/实验高效率方法的用户极其友好
### 多模态学习进程
与 Anki 的 SQLite `.apkg` 包不同, 我们坚持使用人类可读的文件夹组织单元集, 这带来了若干好处, 包括:
- 人类可读: 您可以用任意工具, 乃至一个记事本自由修改记忆载荷数据而无需打开软件
- 元数据配置: 配置自由度极高, 可以任意组合, 重造, 乃至创造新内容
- 测验, 算法与知识互相隔离: 一条知识不再是单一的闪卡, 不仅可以用若干不同的算法规划, 还可以用多种并行的谜题类型测验, 极大地提升学习效果和丰富度. 作为学习者, 您无需担忧概念复杂--仅需从云端下载单元集即可开箱即用上述特性!
- 多模态学习
- 软件自身集成了文本转语音 (TTS) , 音频与语言模型 (LLM) 模块, 这些功能乃至功能本身都是可插拔, 可扩展, 可切换驱动的, 这为内容创建了极大的丰富度
- 软件内置多种谜题类型, 包括选择题 (MCQ), 填空题 (Cloze) 与识别题 (Recognition), 您可在同一单元应用多种, 或是选择性启用
- 软件天然支持动态内容生成, 支持宏驱动的模板系统, 根据上下文乃至语言模型动态生成知识点的解析
- 在间隔重复研究尚被 SuperMemo 系列独占的时代, Wozniak 就早已表示 "如果不能理解知识, 就无需记忆它". 今天, 我们依然相信理解是记忆的基石
- 云同步与分享优化:
- 由于记忆数据和单元集文件都是文本文件, 故可进行快速的增量同步而无需完整地上传所有文件, 并且设计天然支持版本控制
- 如果您想分享单文件, 软件也支持导出为压缩包或合并成单文本文件以通过纯文本文件形式在 pastebin 等平台分享
- 性能提升: 得益于现代且支持分块的文件组织结构, 潜进能在保持高自由度的同时仅使用 python 就能达到敏捷且低占用的用户体验
- AI 辅助友好: 想象您有一些 `.apkg` 牌组或一大段教材内容, 您可以方便且高效率地使用 AI 工具以创建可在 HeurAMS 使用的单元集
### 内置实用用户界面
尽管不是唯一前端, 但响应式 Textual 框架构建的内置终端用户界面在多种场景下仍具有独特优势:
- 跨平台, 并支持触屏/鼠标/键盘多操作模式
- 与几乎所有现代终端模拟器相容
- 对于<a href="https://www.arewesixelyet.com/" target="_blank" rel="noopener noreferrer">支持 sixel 协议的终端模拟器</a>, 可高清显示图像内容
- 对于不支持 sixel 协议的终端模拟器, 也支持图片低清的兼容显示模式
- 可通过 textual-web 作为服务部署, 并在任意浏览器使用
- 简洁直观, 键盘友好, 全功能且高效率的用户界面设计
- 易于嵌入: 可在 getty/kmscon 中运行而无需任何桌面图形服务
- 资源占用小, 运行流畅, 不拖泥带水
- 便于测试与调试程序库
查看[屏幕截图](SCREENSHOTS_zh.md).
## 包依赖组说明
由于部分依赖只被少数功能需要, 所以我们把可选依赖分得比较细, 前面提供的命令会安装部分可选依赖, 以下是依赖组列表:
| 依赖组 | 包含模块 | 说明 |
|--------|----------|------|
| 构建系统 | hatchling | 构建时安装 |
| 最小化安装 | tabulate, toml, transitions, click | 核心驱动程序库, 始终必需 |
| interface | textual | 基本用户界面依赖 |
| algo-fsrs | fsrs | FSRS 算法模块 |
| tts-edgetts | edge-tts | 微软文本转语音 |
| llm | llms-py | API 调用 |
| audio-playsound | playsound3 | 通用音频模块 |
| dev | zmq, pytest, pytest-cov | 开发调试与测试工具 |
| basic | [tts-edgetts], [llm], [algo-fsrs] | 适用于用户体验的较轻依赖组(推荐) |
| all | 以上所有依赖 | 完整安装组 |
## 关于此仓库
此仓库为 HeurAMS "潜进" 的核心程序库在 python 语言下的实现\
包含数据模型与框架, 并内置了基于 textual 框架的前端实现 (interface 子模块)\
除了通过内置前端进行学习外, 开发者也能在 python 环境中导入 `heurams` 库或使用 `RPC``heurams` 程序库实例通讯, 使用框架构建其他辅助记忆功能前端或其他应用程序
项目组的所有仓库如下:
| 项目名称 | 状态 | 说明 | 包名 | 技术栈 | 目标平台 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| HeurAMS | 开发中<br/>原型可用 | 提供通用核心程序库与基本用户界面 | `heurams` | Python | 标准 Python 环境 |
| KiriMemo | 开发中<br/>原型可用 | 基于 KDE 技术的现代跨平台前端 | `org.kde.kirimemo` | C++, Qt6, Kirigami, PyOtherSide | 桌面与移动设备 |
| ArkMemo | 开发中 | 基于 ArkUI 的现代跨平台前端 | `top.pluv27.arkmemo` | ArkTS, ArkUI | 移动设备 |
| HeurStudio | 计划中 | AI 辅助的单体单元集高级创建与编辑工具 | `top.pluv27.heurstudio` | C++, Qt6, Kirigami, PyOtherSide | 桌面 |
| HeurSync | 开发中 | 用户数据同步服务器<br/>集成 Web 前端与排行榜 | `heursync` | Go, SQL | 网页与服务器 |
| HeurRepo | 开发中 | 单元集文档源服务器<br/>与单元集分享平台 | `heurrepo` | Go, SQL | 网页与服务器 |
尽管现在后三样有点画大饼的意思, 但是我们的路线是明了的
+73
View File
@@ -0,0 +1,73 @@
# User Interface Screenshots
The HeurAMS project currently has two frontend implementations. This document presents their screenshots (kept as up-to-date as possible):
- **Textual Basic User Interface** (`heurams.interface`): A built-in cross-platform TUI built with the Python Textual framework, supporting touch, mouse, and keyboard operation modes. This is the current out-of-the-box default frontend.
- **KiriMemo** (`org.kde.kirimemo`): A modern cross-platform frontend based on the KDE Kirigami framework, built with C++ and QML, directly reusing the Python kernel via `PyOtherSide`, providing native experiences across multiple platforms (not yet stable).
<!--- ArkMemo (top.pluv27.arkmemo): A modern mobile device frontend based on ArkUI, built with ArkTS, calling the Python kernel via API, providing native experiences for Android, HarmonyOS, and iOS platforms (not yet stable)-->
Feel free to contribute code to existing frontends or develop your own.\
See the [Contributing Guide](../CONTRIBUTING.md#new-user-interface-frontends).
## Screenshots of the Basic User Interface
> The terminal emulator used for screenshots is KDE Konsole.\
> Fonts used: Cascadia Code and Noto Sans SC.\
> Terminal size set to 80x25 (the software also supports larger terminal sizes).
### Dashboard & Navigator
The dashboard provides an overview of the learning panel, including entry points for different functional areas, statistics, and a unit set overview.\
The navigator is a practical modal window that lets you quickly switch between various features. Press the `n` key or click the button below to open/close the navigator from any screen.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/dashboard_1.png" width="48%">
<img src="../screenshots/dashboard_2.png" width="48%">
<img src="../screenshots/navigator_1.png" width="48%">
</div>
### Preparation Screen & Pre-cache Tool
The preparation screen shows unit set basic information and the learning status of each unit, providing entry points for learning and pre-caching.\
The pre-cache tool allows you to pre-cache TTS resources to ensure a smooth review experience and offline review capability. Even without pre-caching, resources are loaded automatically during review playback.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/preparation.png" width="48%">
<img src="../screenshots/precache_1.png" width="48%">
</div>
### Review Queue Screen
The main screen for queue-based learning and memorization.\
The same knowledge point can generate multiple puzzle types for evaluation. The software includes built-in Cloze, Recognition, and other test types, allowing you to complete different tests sequentially during the review flow.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/memoqueue_cloze_1.png" width="48%">
<img src="../screenshots/memoqueue_recognition_1.png" width="48%">
<img src="../screenshots/memoqueue_recognition_2.png" width="48%">
</div>
### Settings Screen
The configuration screen includes algorithm selection, provider switching for audio and various services, as well as interface and algorithm settings.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/setting_1.png" width="48%">
<img src="../screenshots/setting_2.png" width="48%">
</div>
### Other Screens
The favorites manager lets you manage your manually marked personal collections.\
The about page provides program version number, license information, and more.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/about_1.png" width="48%">
<img src="../screenshots/favmanager_1.png" width="48%">
</div>
## Screenshots of the KiriMemo Frontend
Screenshots will be added when the KiriMemo frontend development stabilizes.
<!-- TODO: Add screenshots -->
+73
View File
@@ -0,0 +1,73 @@
# 用户界面屏幕截图
潜进 (HeurAMS) 项目目前有两个前端实现, 此文档用于呈现它们的截图 (尽量与最新版本同步):
- Textual 基本用户界面 (heurams.interface): 基于 Python Textual 框架构建的程序库内置跨平台 TUI 界面, 支持触屏、鼠标、键盘多操作模式, 是当前开箱即用的默认前端.
- KiriMemo (org.kde.kirimemo): 基于 KDE Kirigami 框架的现代跨平台前端, 使用 C++ 和 QML 构建, 通过 `PyOtherSide` 直接复用 Python 内核, 为多种平台提供原生体验 (尚未稳定).
<!--- ArkMemo (top.pluv27.arkmemo): 基于 ArkUI 的现代移动设备前端, 使用 ArkTS 构建, 通过 API 调用 Python 内核, 为 Android, HarmonyOS, iOS 平台提供原生体验 (尚未稳定)-->
欢迎为现有前端贡献代码, 或开发您自己的前端.\
详见[贡献指南](../CONTRIBUTING_zh.md#%E6%96%B0%E7%9A%84%E7%94%A8%E6%88%B7%E7%95%8C%E9%9D%A2%E5%89%8D%E7%AB%AF).
## 基本用户界面前端的截图
> 截图所使用的终端模拟器为 KDE Konsole\
> 字体为 Cascadia Code 和 Noto Sans SC\
> 终端尺寸设置为 80x25 (软件也支持更大的终端尺寸)
### 仪表盘与导航器
仪表盘包含学习面板的总体视图, 包括不同功能区域的操作入口, 统计信息, 以及单元集概览.\
导航器是一个实用的模态窗口, 能带您在多种功能间自如切换, 按 `n` 键或单击下方按钮可在任意界面迅速打开/关闭导航器.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/dashboard_1.png" width="48%">
<img src="../screenshots/dashboard_2.png" width="48%">
<img src="../screenshots/navigator_1.png" width="48%">
</div>
### 准备界面与预缓存工具
学习准备界面展示了单元集基本信息和每个单元的学习状态, 并提供了学习和预缓存的入口.\
预缓存工具使您能提前预缓存文本转语音资源以确保复习流程的顺畅体验和离线复习能力, 但即使您不预先缓存, 资源也会在复习播放时被自动加载.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/preparation.png" width="48%">
<img src="../screenshots/precache_1.png" width="48%">
</div>
### 记忆队列界面
队列式学习记忆的主要界面.\
同一知识点可产生多种谜题类型的评估方式, 软件内置完形填空与识别题等多种测试类型, 您可在复习流程中按顺序完成不同测试.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/memoqueue_cloze_1.png" width="48%">
<img src="../screenshots/memoqueue_recognition_1.png" width="48%">
<img src="../screenshots/memoqueue_recognition_2.png" width="48%">
</div>
### 设置界面
配置界面包含算法选择、音频与多种服务的提供者切换、以及界面与算法设置等选项.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/setting_1.png" width="48%">
<img src="../screenshots/setting_2.png" width="48%">
</div>
### 其他界面
收藏管理器可管理您手动标记的个人收藏集.\
关于页面提供了程序版本号、许可协议等信息.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="../screenshots/about_1.png" width="48%">
<img src="../screenshots/favmanager_1.png" width="48%">
</div>
## KiriMemo 前端的截图
截图将在 KiriMemo 前端开发趋于稳定后补充.
<!-- TODO: 补充截图 -->
+76 -20
View File
@@ -1,27 +1,83 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "heurams"
version = "0.4.0"
description = "Heuristic Assisted Memory Scheduler"
license = {file = "LICENSE"}
version = "0.5.1"
authors = [{ name = "Wang Zhiyu", email = "pluvium27@outlook.com" }]
description = "Heuristic Auxiliary Memory Scheduler"
readme = "README.md"
requires-python = ">=3.12"
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"
license = "AGPL-3.0-or-later"
license-files = ["LICENSE"]
[tool.setuptools.packages.find]
where = ["src"]
dependencies = [ # 这些依赖只能驱动 kernel 程序库
"click>=8.3.3",
"fastapi>=0.136.1",
"tabulate>=0.10.0",
"toml>=0.10.2",
"transitions>=0.9.3",
"uvicorn[standard]>=0.47.0",
]
[project.optional-dependencies]
interface = [ # 基本用户界面依赖
"textual>=8.2.5",
]
algo-fsrs = [
"fsrs>=6.3.1", # FSRS 算法底层依赖
]
tts-edgetts = [
"edge-tts>=7.2.8", # 微软 TTS
]
llm = ["llms-py>=3.0.0"]
audio-playsound = ["playsound3>=3.3.1"]
dev = [ # 调试所需依赖
"zmq>=0.0.0", # 用于 ZMQ 远程调试服务器, 在 linux 上建议先安装 libzmq
"pytest>=8.0.0", # 用于普通测试
"pytest-cov>=6.0.0",
]
all = [
"heurams[algo-fsrs]",
"heurams[tts-edgetts]",
"heurams[llm]",
"heurams[audio-playsound]",
"heurams[dev]",
]
basic = [
"heurams[algo-fsrs]",
"heurams[interface]",
"heurams[tts-edgetts]",
"heurams[llm]",
]
[project.urls]
Homepage = "https://ams.pluv27.top"
Issues = "https://github.com/heurams/heurams/issues"
[[tool.uv.index]]
url = "https://mirrors.ustc.edu.cn/pypi/simple"
default = true
[project.scripts]
heurams = "heurams.__main__:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
pythonpath = ["src"]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"httpx>=0.28.1",
]
-4
View File
@@ -1,4 +0,0 @@
bidict==0.23.1
playsound==1.2.2
textual==5.3.0
toml==0.10.2
Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

+1 -4
View File
@@ -1,7 +1,4 @@
print("欢迎使用 HeurAMS 及其组件!")
# 补充日志记录
from heurams.services.logger import get_logger
logger = get_logger(__name__)
logger.info("欢迎使用 HeurAMS 及其组件!")
logger.info("HeurAMS is imported")
+118 -7
View File
@@ -1,7 +1,118 @@
prompt = """HeurAMS 已经被成功地安装在系统中.
但 HeurAMS 被设计为一个带有辅助记忆调度器功能的软件包, 无法直接被执行, 但可被其他 Python 程序调用.
若您想启动内置的基本用户界面,
请运行 python -m heurams.interface,
或者 python -m heurams.interface.__main__
注意: 一个常见的误区是, 执行 interface 下的 __main__.py 运行基本用户界面, 这会导致 Python 上下文环境异常, 请不要这样做."""
print(prompt)
"""命令行入口
"""
import platform
import click
from heurams.i18n import _, setup_locale
from heurams.services.version import ver, stage, codename, codename_cn
from heurams.services.logger import get_logger
logger = get_logger(__name__)
def _apply_locale(ctx, param, value):
"""立即显式应用 locale"""
if value:
setup_locale(value)
return value
class _I18nGroup(click.Group):
"""在运行时完成翻译工作的命令组"""
_option_help_map: dict[str, str] = {
"version": "Show the version and exit.",
"locale": "Explicitly specify locale (defaults to LANG env).",
"host": "Listening address",
"port": "Listening port",
"reload": "Development mode hot reload",
}
def format_help(self, ctx, formatter):
# 重新翻译每个子命令的帮助信息
for cmd in self.commands.values():
raw = getattr(cmd, "_raw_help", None)
if raw is not None:
cmd.help = _(raw)
# Re-translate every option's help text by name lookup.
for param in self.params:
if isinstance(param, click.Option) and param.name in self._option_help_map:
param.help = _(self._option_help_map[param.name])
self.help = _("HeurAMS {ver} - Heuristic Auxiliary Memorizing Scheduler").format(
ver=ver
)
return super().format_help(ctx, formatter)
class _I18nCommand(click.Command):
"""在运行时完成翻译工作的命令"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._raw_help = self.help or (self.short_help or "")
@click.group(
cls=_I18nGroup,
invoke_without_command=True,
context_settings={"help_option_names": ["-h", "--help"]},
)
@click.version_option(
ver, "-v", "--version",
prog_name="HeurAMS",
message=f"%(prog)s %(version)s {stage} ({codename}/{codename_cn}), {platform.system()}",
help=_I18nGroup._option_help_map["version"],
)
@click.option(
"--locale", "-l", default=None,
callback=_apply_locale,
is_eager=True,
expose_value=True,
help=_I18nGroup._option_help_map["locale"],
)
@click.pass_context
def cli(ctx, locale):
if ctx.invoked_subcommand is None:
click.echo(cli.get_help(ctx))
ctx.exit(0)
@cli.command(cls=_I18nCommand)
def tui():
"""Launch the built-in user interface (TUI)"""
import heurams.interface.__main__ as tui_module
tui_module.main()
@cli.command(cls=_I18nCommand)
@click.option("--host", default="127.0.0.1", help=_I18nGroup._option_help_map["host"])
@click.option("--port", default=8821, help=_I18nGroup._option_help_map["port"], type=int)
@click.option("--reload", is_flag=True, help=_I18nGroup._option_help_map["reload"])
def serve(host, port, reload):
"""Launch the API service (unifront)"""
from heurams.unifront.server import create_app
app = create_app()
click.echo(
_("unifront API service started: http://{host}:{port}").format(
host=host, port=port
)
)
import uvicorn
uvicorn.run(app, host=host, port=port, reload=reload, log_level="info")
@cli.command(cls=_I18nCommand, name="help")
@click.pass_context
def help_cmd(ctx):
"""Show this help message"""
click.echo(cli.get_help(ctx.parent))
def main():
cli()
if __name__ == "__main__":
logger.info("HeurAMS cmdline entrypoint invoked")
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 554.4 554.4" version="1.1">
<defs>
<style type="text/css">*{stroke-linejoin: round; stroke-linecap: butt}</style>
</defs>
<title>HeurAMS Monochrome Logo - Dark</title>
<desc>Dark monochrome logo of the HeurAMS Project</desc>
<g id="figure_1">
<g id="patch_1">
<path d="M 0 554.4 L 554.4 554.4 L 554.4 0 L 0 0 L 0 554.4 z" style="fill: none"/>
</g>
<g id="axes_1">
<g id="patch_2">
<path d="M 66.528 487.872 L 199.584 487.872 L 199.584 354.816 L 66.528 354.816 z" clip-path="url(#p4da876c7a0)" style="fill: #1A1A1A; stroke: #1A1A1A; stroke-linejoin: miter"/>
</g>
<g id="patch_3">
<path d="M 199.584 354.816 L 487.872 354.816 L 487.872 66.528 L 199.584 66.528 z" clip-path="url(#p4da876c7a0)" style="fill: #1A1A1A; stroke: #1A1A1A; stroke-linejoin: miter"/>
</g>
</g>
</g>
<defs>
<clipPath id="p4da876c7a0">
<rect x="0" y="0" width="554.4" height="554.4"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 554.4 554.4" version="1.1">
<defs>
<style type="text/css">*{stroke-linejoin: round; stroke-linecap: butt}</style>
</defs>
<title>HeurAMS Monochrome Logo - Light</title>
<desc>Light monochrome logo of the HeurAMS Project</desc>
<g id="figure_1">
<g id="patch_1">
<path d="M 0 554.4 L 554.4 554.4 L 554.4 0 L 0 0 L 0 554.4 z" style="fill: none"/>
</g>
<g id="axes_1">
<g id="patch_2">
<path d="M 66.528 487.872 L 199.584 487.872 L 199.584 354.816 L 66.528 354.816 z" clip-path="url(#p4da876c7a0)" style="fill: #ffffff; stroke: #ffffff; stroke-linejoin: miter"/>
</g>
<g id="patch_3">
<path d="M 199.584 354.816 L 487.872 354.816 L 487.872 66.528 L 199.584 66.528 z" clip-path="url(#p4da876c7a0)" style="fill: #ffffff; stroke: #ffffff; stroke-linejoin: miter"/>
</g>
</g>
</g>
<defs>
<clipPath id="p4da876c7a0">
<rect x="0" y="0" width="554.4" height="554.4"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100%" height="100%" viewBox="0 0 554.4 554.4" version="1.1">
<defs>
<style type="text/css">*{stroke-linejoin: round; stroke-linecap: butt}</style>
</defs>
<title>HeurAMS Logo</title>
<desc>The logo of the HeurAMS Project</desc>
<g id="figure_1">
<g id="patch_1">
<path d="M 0 554.4 L 554.4 554.4 L 554.4 0 L 0 0 L 0 554.4 z" style="fill: none"/>
</g>
<g id="axes_1">
<g id="patch_2">
<path d="M 66.528 487.872 L 199.584 487.872 L 199.584 354.816 L 66.528 354.816 z" clip-path="url(#p4da876c7a0)" style="fill: #1660a5; stroke: #1660a5; stroke-linejoin: miter"/>
</g>
<g id="patch_3">
<path d="M 199.584 354.816 L 487.872 354.816 L 487.872 66.528 L 199.584 66.528 z" clip-path="url(#p4da876c7a0)" style="fill: #545f70; stroke: #545f70; stroke-linejoin: miter"/>
</g>
</g>
</g>
<defs>
<clipPath id="p4da876c7a0">
<rect x="0" y="0" width="554.4" height="554.4"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+5
View File
@@ -0,0 +1,5 @@
_services_desc = '服务模块设置'
_providers_desc = '驱动模块设置'
_repo_desc = '单元集独立设置'
_interface_desc = '基本用户界面设置'
_global_desc = '底层设置'
@@ -0,0 +1,21 @@
zmq_debug = false
_zmq_debug_desc = "[调试] ZeroMQ 调试服务器, 这会在 zmq_debug_port 上打开一个服务器\n调试工具可远程在 HeurAMS 内执行任意 python 代码, 无必要请关闭"
zmq_debug_port = 5555
_zmq_debug_port_desc = "[调试] ZeroMQ 调试服务器端口"
enable_built_in_interface = true
_enable_built_in_interface_desc = "启用内置基本用户界面\n(当且仅当 HeurAMS 作为程序库时禁用, 以跳过用户界面逻辑)"
_paths_desc = "用户数据路径定义"
[paths]
data = "./data"
_data_desc = "用户数据根目录"
cache = "./data/cache"
_cache_desc = "缓存根目录\n(如音频缓存在 voice 子目录)"
config = "./data/config"
_config_desc = "配置文件根目录"
repo = "./data/repo"
_repo_desc = "记忆单元集根目录"
misc = "./data/misc"
_misc_desc = "扩展程序和 whisper 等模块的数据存储根目录"
_addons = "./data/addons"
__addons_desc = "扩展程序根目录"
@@ -0,0 +1,4 @@
_global_desc = "用户界面通用设置"
_widgets_desc = "组件设置"
_screens_desc = "界面设置"
_puzzles_desc = "谜题生成器设置"
@@ -0,0 +1,25 @@
show_header = true
_show_header_desc = "展示界面顶部的标题栏\n如果您想节省这一行空间, 可以禁用它"
clock_on_header = true
_clock_on_header_desc = "在界面顶部的标题栏显示时间"
change_window_title = true
_change_window_title_desc = "更改终端模拟器窗口的标题\n如果禁用了 header, 则建议启用"
persist_to_file = true
_persist_to_file_desc = "[调试] 将记忆更改保存到文件"
quick_pass = true
_quick_pass_desc = "[调试] 启用快速应答功能(跳过测验)"
auto_pass = false
_auto_pass_desc = "[调试] 自动通过测试模式"
scheduled_num = 35
_scheduled_num_desc = "默认记忆单元数量\n可被单元集设置覆盖"
refresh_on_resume = true
_refresh_on_resume_desc = "[调试] 每当 Screen 激活后都刷新状态"
algorithm = "SM-2"
_algorithm_desc = "默认记忆调度算法\n可被单元集设置覆盖"
[_algorithm_candidate]
NSP-0 = "筛选用非间隔重复调度器"
none = "不设置默认调度器"
SM-2 = "第二代 SuperMemo 简单间隔重复调度器\nWozniak 于 1987 年提出, Anki 的默认算法"
SM-15M = "类第15代 SuperMemo 复杂间隔重复调度器\n不稳定且逆向工程"
FSRS = "先进开放间隔重复调度器"
@@ -0,0 +1,2 @@
_cloze_desc = "填空题"
_mcq_desc = "选择题"
@@ -0,0 +1,2 @@
min_denominator = " 1"
_min_denominator_desc = "设空比例系数的倒数"
@@ -0,0 +1,2 @@
max_riddles_num = "2"
_max_riddles_num_desc = "单次生成的最大谜题数量"
@@ -0,0 +1 @@
_recognition_desc = "用于 '辨识' 组件的设置"
@@ -0,0 +1,2 @@
autovoice = true
_autovoice_desc = "自动语音播放"
@@ -0,0 +1 @@
_tts_desc = '文本转语音驱动'
@@ -0,0 +1 @@
_edgetts_desc = "微软文本转语音驱动"
@@ -0,0 +1,25 @@
voice = "zh-CN-XiaoxiaoNeural"
_voice_desc = "音色"
[_voice_candidate]
zh-CN-XiaoxiaoNeural = "晓晓: 中文温柔女声"
zh-CN-XiaoyiNeural = "晓伊: 中文甜美女声"
zh-CN-XiaochenNeural = "晓辰: 中文知性女声"
zh-CN-XiaohanNeural = "晓涵: 中文优雅女声"
zh-CN-XiaomengNeural = "晓梦: 中文梦幻女声"
zh-CN-XiaomoNeural = "晓墨: 中文文艺女声"
zh-CN-XiaoqiuNeural = "晓秋: 中文成熟女声"
zh-CN-XiaoruiNeural = "晓睿: 中文智慧女声"
zh-CN-XiaoshuangNeural = "晓双: 中文活泼女声"
zh-CN-XiaoxuanNeural = "晓萱: 中文清新女声"
zh-CN-XiaoyanNeural = "晓颜: 中文柔美女声"
zh-CN-XiaoyouNeural = "晓悠: 中文悠扬女声"
zh-CN-XiaozhenNeural = "晓甄: 中文端庄女声"
zh-CN-YunxiNeural = "云希: 中文清朗男声"
zh-CN-YunyangNeural = "云扬: 中文阳光男声"
zh-CN-YunjianNeural = "云健: 中文稳重男声"
zh-CN-YunfengNeural = "云枫: 中文磁性男声"
zh-CN-YunhaoNeural = "云皓: 中文豪迈男声"
zh-CN-YunxiaNeural = "云夏: 中文热情男声"
zh-CN-YunyeNeural = "云野: 中文野性男声"
zh-CN-YunzeNeural = "云泽: 中文深沉男声"
@@ -0,0 +1,2 @@
_cngk-t_desc = "高考必备古诗文-测试"
_cngk_desc = "高考必备古诗文"
@@ -0,0 +1,5 @@
_audio_desc = '音频服务'
_llm_desc = '语言模型服务'
_sync_desc = '数据同步服务'
_timer_desc = '时间服务'
_tts_desc = '文本转语音服务'
@@ -0,0 +1,9 @@
provider = "playsound"
_provider_desc = "音频驱动类型"
[_provider_candidate]
playsound = "Python 桌面跨平台音频系统, 使用了 playsound3 替代了老旧的 playsound"
termux = "Android Termux 音频系统"
mpg123 = "通用音频系统, 依赖系统 mpg123"
pulseaudio = "高级音频路由系统"
none = "不使用音频"
@@ -0,0 +1,6 @@
provider = "none"
_provider_desc = "模型接口类型"
[_provider_candidate]
openai = "OpenAI 风格 API, 同时支持与其相容的模型服务 (如 deepseek)"
none = "不使用语言大模型"
@@ -0,0 +1,7 @@
provider = "none"
_provider_desc = "同步服务驱动类型"
[_provider_candidate]
webdav = "WebDAV 兼容网络文件系统 (包括 webdavs)"
official = "官方同步服务器"
none = "不使用同步服务器"
@@ -0,0 +1,6 @@
daystamp_override = -1
_daystamp_override_desc = "[调试] 覆写 UNIX 日时间戳, 单位为日\n(设为 -1 禁用)"
timestamp_override = -1
_timestamp_override_desc = "[调试] 覆写 UNIX 时间戳, 单位为秒\n(设为 -1 禁用)"
timezone_offset = 28800
_timezone_offset_desc = "时区偏移设置, 用于取消跨天时区误差, 单位为秒\n(如 28800 为 UTC+8.0, 中国标准时间)"
@@ -0,0 +1,7 @@
provider = "edgetts"
_provider_desc = "文本转语音驱动类型"
[_provider_candidate]
edgetts = "微软神经网络语音合成, 依赖微软网络服务"
espeak = "低保真度本地语音合成"
none = "不使用文本转语音"
+34 -29
View File
@@ -1,44 +1,49 @@
"""
全局上下文管理模块
以及基准路径
"""全局上下文模块
初始化并管理基准路径, 程序配置对象, 并提供调试所需上下文管理器
"""
from contextvars import ContextVar
import pathlib
from heurams.services.config import ConfigFile
from contextvars import ContextVar
from heurams.services.config import ConfigDict
from heurams.services.logger import get_logger
# 默认配置文件路径规定: 以包目录为准
# 用户配置文件路径规定: 以运行目录为准
# 数据文件路径规定: 以运行目录为准
# 默认数据目录, 以包目录下的 data 为准
# 用户数据目录, 以运行目录下的 data 为准
rootdir: pathlib.Path = pathlib.Path(__file__).parent
"""包目录路径, 也就是 heurams 目录."""
rootdir = pathlib.Path(__file__).parent
print(f"rootdir: {rootdir}")
logger = get_logger(__name__)
logger.debug(f"项目根目录: {rootdir}")
workdir = pathlib.Path.cwd()
print(f"workdir: {workdir}")
logger.debug(f"工作目录: {workdir}")
config_var: ContextVar[ConfigFile] = ContextVar(
"config_var", default=ConfigFile(rootdir / "default" / "config" / "config.toml")
)
try:
config_var: ContextVar[ConfigFile] = ContextVar(
"config_var", default=ConfigFile(workdir / "config" / "config.toml")
) # 配置文件
print("已加载自定义用户配置")
logger.info("已加载自定义用户配置, 路径: %s", workdir / "config" / "config.toml")
except Exception as e:
print("未能加载自定义用户配置")
logger.warning("未能加载自定义用户配置, 错误: %s", e)
"""工作目录路径."""
# runtime_var: ContextVar = ContextVar('runtime_var', default=dict()) # 运行时共享数据
logger = get_logger(__name__)
logger.info(f"rootdir: {rootdir}")
logger.info(f"workdir: {workdir}")
default_data = rootdir / "assets" / "data"
user_data = workdir / "data"
if not user_data.exists():
logger.info("Create a new data directory: %s", user_data)
import shutil
shutil.copytree(default_data, user_data)
else:
(workdir / "data" / "config").mkdir(parents=True, exist_ok=True)
config_var: ContextVar[ConfigDict] = ContextVar(
"config_var",
default=ConfigDict(workdir / "data" / "config"),
)
"""配置对象的全局引用对象."""
class ConfigContext:
"""
功能完备的上下文管理器
用于临时切换配置的作用域, 支持嵌套使用
用于临时切换配置引用对象的作用域, 支持嵌套使用
Example:
>>> with ConfigContext(test_config):
@@ -46,7 +51,7 @@ class ConfigContext:
>>> get_daystamp() # 恢复原配置
"""
def __init__(self, config_provider: ConfigFile):
def __init__(self, config_provider: ConfigDict):
self.config_provider = config_provider
self._token = None
-29
View File
@@ -1,29 +0,0 @@
# [调试] 将更改保存到文件
persist_to_file = 1
# [调试] 覆写时间, 设为 -1 以禁用
daystamp_override = -1
timestamp_override = -1
# [调试] 一键通过
quick_pass = 0
# 对于每个项目的默认新记忆原子数量
tasked_number = 8
# UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒
timezone_offset = +28800 # 中国标准时间 (UTC+8)
[puzzles] # 谜题默认配置
[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"
+38
View File
@@ -0,0 +1,38 @@
"""国际化支持模块
基于 GNU gettext, 并使用英文 (en_US) 作为 msgid 的基础语言.
"""
import gettext
import os
from pathlib import Path
import locale
from heurams.context import rootdir
_LOCALE_DIR = rootdir / "locale"
_DEFAULT_LOCALE = "en_US"
_translation = gettext.NullTranslations()
def setup_locale(_locale: str | None = None) -> None:
"""获得翻译项.
如果没有找到对应的 locale, 就回退到原始 msgid.
"""
global _translation
if _locale is None:
_locale = locale.getlocale()[0]
try:
_translation = gettext.translation(
"heurams", localedir=str(_LOCALE_DIR), languages=[_locale], fallback=True
)
except FileNotFoundError:
_translation = gettext.NullTranslations()
def _(message: str) -> str:
"""执行翻译的函数"""
return _translation.gettext(message)
# 导入时初始化
setup_locale()
+1
View File
@@ -1,2 +1,3 @@
# Interface - 用户界面
与界面系统**强绑定**的相关代码文件, "界面系统" 在此处是基本界面实现相关的 Textual 框架
+92
View File
@@ -0,0 +1,92 @@
from time import sleep, perf_counter
# import gc
# gc.set_threshold(100, 1, 1)
from heurams.i18n import _
print(_("Welcome to the basic user interface!"))
print(_("Loading config and context... "), end="", flush=True)
_start_all = perf_counter()
_start = _start_all
from heurams.context import rootdir, workdir, config_var
_end = perf_counter()
print(_("Done! ({time}ms)").format(time=round(1000 * (_end - _start))))
print(_("Loading UI framework... "), end="", flush=True)
_start = perf_counter()
from textual.app import App
_end = perf_counter()
print(_("Done! ({time}ms)").format(time=round(1000 * (_end - _start))))
print(_("Loading UI layout... "), end="", flush=True)
_start = perf_counter()
from .screens.dashboard import DashboardScreen
from .screens.navigator import NavigatorScreen
from .screens.precache import PrecachingScreen
from .screens.setting import SettingScreen
from .screens.synctool import SyncScreen
from .screens.about import AboutScreen
from . import shim
_end = perf_counter()
print(_("Done! ({time}ms)").format(time=round(1000 * (_end - _start))))
print(_("Component directory: {path}").format(path=rootdir))
print(_("Working directory: {path}").format(path=workdir))
_end_all = perf_counter()
print(_("Pre-work total: {time}ms").format(time=round(1000 * (_end_all - _start_all))))
class HeurAMSApp(App):
TITLE = "HeurAMS"
CSS_PATH = rootdir / "interface" / "css" / "main.tcss"
SUB_TITLE = _("Heuristic Auxiliary Memorizing Scheduler")
BINDINGS = [
("q", "go_back", _("Quit")),
("d", "toggle_dark", _("Theme")),
("n", "app.push_screen('navigator')", _("Navigate")),
("s", "app.push_screen('setting')", _("Settings")),
("z", "app.push_screen('about')", _("About")),
]
SCREENS = {
"dashboard": DashboardScreen,
"precache_all": PrecachingScreen,
"synctool": SyncScreen,
"about": AboutScreen,
"navigator": NavigatorScreen,
"setting": SettingScreen,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def on_mount(self) -> None:
self.push_screen("dashboard")
def action_go_back(self) -> None:
self.exit() # go_back 在最顶层是退出, Screen 会再次定义为返回, 键位都是 q, 免得不一样
def action_do_nothing(
self,
) -> None: # 用来给没使用/禁用的快捷键占位, 因为 Binding 删除不了
pass
'''
# 移除烦人的 "rich traceback", 但可能导致未定义行为出现, 所以注释掉
# https://github.com/Textualize/textual/discussions/6255
# NOTE: 进行 textual 版本升级时, 确保查看过上游代码, 尤其是 App 的 _exception
# 如果行为变了就把下面的删了 (虽然有 fallback)
def _fatal_error(self):
if hasattr(self, "_exception"):
self._close_messages_no_wait()
raise self._exception
super()._fatal_error() # fallback
def panic(self, *args):
if hasattr("_exception"):
self._close_messages_no_wait()
raise self._exception
super().panic(*args) # ditto
'''
+45 -79
View File
@@ -1,87 +1,53 @@
from textual.app import App
from textual.widgets import Button
from .screens.dashboard import DashboardScreen
from .screens.nucreator import NucleonCreatorScreen
from .screens.precache import PrecachingScreen
from .screens.about import AboutScreen
from heurams.interface import *
from heurams.context import config_var
from heurams.i18n import _
from heurams.services.logger import get_logger
import threading
import pickle
logger = get_logger(__name__)
class HeurAMSApp(App):
TITLE = "潜进"
CSS_PATH = "css/main.tcss"
SUB_TITLE = "启发式辅助记忆调度器"
BINDINGS = [
("q", "quit", "退出"),
("d", "toggle_dark", "切换色调"),
("1", "app.push_screen('dashboard')", "仪表盘"),
("2", "app.push_screen('precache_all')", "缓存管理器"),
("3", "app.push_screen('nucleon_creator')", "创建新单元"),
("0", "app.push_screen('about')", "版本信息"),
]
SCREENS = {
"dashboard": DashboardScreen,
"nucleon_creator": NucleonCreatorScreen,
"precache_all": PrecachingScreen,
"about": AboutScreen,
}
def on_mount(self) -> None:
self.push_screen("dashboard")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.exit(event.button.id)
def action_do_nothing(self):
print("DO NOTHING")
self.refresh()
def environment_check():
from pathlib import Path
logger.debug("检查环境路径")
for i in config_var.get()["paths"].values():
i = Path(i)
if not i.exists():
logger.info("创建目录: %s", i)
print(f"创建 {i}")
i.mkdir(exist_ok=True, parents=True)
else:
logger.debug("目录已存在: %s", i)
print(f"找到 {i}")
logger.debug("环境检查完成")
def is_subdir(parent, child):
try:
child.relative_to(parent)
logger.debug("is_subdir: %s%s 的子目录", child, parent)
return 1
except:
logger.debug("is_subdir: %s 不是 %s 的子目录", child, parent)
return 0
# 开发模式
from heurams.context import rootdir, workdir, config_var
from pathlib import Path
from heurams.context import rootdir
import os
if is_subdir(Path(rootdir), Path(os.getcwd())):
os.chdir(Path(rootdir) / ".." / "..")
print(f'转入开发数据目录: {Path(rootdir)/".."/".."}')
environment_check()
app = HeurAMSApp()
if __name__ == "__main__":
app.run()
def start_debug_server(app):
import zmq
logger = get_logger("zmq_debug")
context = zmq.Context()
socket = context.socket(zmq.REP)
port = config_var.get()["global"].get("zmq_debug_port", 5555)
socket.bind(f"tcp://*:{port}")
logger.info(f"ZMQ Debug server started on port {port}")
first = 1
while True:
msg = socket.recv()
code = pickle.loads(msg)
namespace = {"app": app, "logger": logger, "config_var": config_var}
if first:
app.title += _(" [Debug Connected]")
first = 0
try:
# 先尝试 eval
result = eval(code, namespace)
socket.send(pickle.dumps(f"成功: {result}"))
except SyntaxError:
# 再尝试 exec
try:
exec(code, namespace)
socket.send(pickle.dumps(f"成功: 执行完成"))
except Exception as e:
socket.send(pickle.dumps(f"错误: {e}"))
except Exception as e:
socket.send(pickle.dumps(f"错误: {e}"))
def main():
app.run()
app = HeurAMSApp()
if config_var.get()["global"].get("zmq_debug", False):
threading.Thread(target=start_debug_server, args=(app,), daemon=True).start()
app.run(inline=False)
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
NavigatorScreen {
align: center middle;
}
#dialog {
grid-size: 2;
grid-gutter: 1 1;
grid-rows: 1fr 3;
padding: 0 1;
width: 46;
height: 12;
border: thick $background 80%;
background: $surface;
}
.favorite-item {
height: auto;
}
.favorite-item-btn {
dock: right;
width: 8;
padding: 0;
}
.memoqueue-container {
padding: 1 2;
width: auto;
}
.memoqueue-container > * {
border: heavy $secondary;
width: 1fr;
padding: 0 1 0 1;
}
@@ -0,0 +1,23 @@
.repo-list {
}
#header {
height: 3;
}
#analysis {
margin: 0;
padding: 0;
}
.repo-list-item {
layout: grid;
grid-size: 1;
height: 3;
}
.repo-list-item-shortcut {
dock: right;
offset: -5% 0
}
@@ -0,0 +1,8 @@
#puzzle_container > * {
height: auto;
width: auto;
}
.hori {
height: auto;
}
@@ -0,0 +1,24 @@
#operations {
height: auto;
}
.btn {
margin: 0 1 0 0;
padding: 0 1 0 1;
}
#main_container {
}
#previewer_container {
}
.unit-statline {
}
#title {
padding: 0;
}
@@ -0,0 +1,23 @@
.foot {
align-vertical: bottom;
}
.setting-switch {
dock: right;
}
Select {
width: 55%;
dock: right;
}
Input {
width: 55%;
dock: right;
}
.setting-item {
width: 100%;
height: 4;
padding: 0 0 1 0;
}
+130 -56
View File
@@ -1,86 +1,104 @@
#!/usr/bin/env python3
"""About screen"""
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Static,
Button,
Markdown,
)
from textual.containers import ScrollableContainer, ScrollableContainer
from textual.containers import ScrollableContainer
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Label, Markdown
from textual import events, on
import heurams.services.version as version
from heurams.context import *
from heurams.i18n import _
import platform
import shutil
import os
import sys
class AboutScreen(Screen):
BINDINGS = [
("q", "go_back", _("Back")),
("z", "go_back", _("About")),
]
SUB_TITLE = _("About")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="about_container"):
yield Label("[b]关于与版本信息[/b]")
about_text = f"""
# 关于 "潜进"
yield Label(_("[b]About & Version Info[/b]"))
# Get system info
textual_version = self._get_textual_version()
terminal_info = self._get_terminal_info()
python_version = self._get_python_version()
os_version = self._get_os_version()
disk_usage = self._get_disk_usage()
版本 {version.ver} {version.stage.capitalize()}
about_text = _(
"""# About HeurAMS
开发代号: {version.codename.capitalize()}
Main library version: `{ver}-python`
UI frontend: `Textual TUI (Basic UI)`
UI version: `{ver}`
API codename: `{codename}`
一个基于启发式算法的开放源代码记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划.
> A heuristic auxiliary memorizing scheduler based on heuristic algorithms and cognitive science theories, designed to help users memorize and plan learning more efficiently.
> An open, elegant, and extensible spaced repetition scheduler experimental platform, designed to help researchers conduct investigations, experiments, and research on cutting-edge memory algorithms more efficiently.
以 AGPL-3.0 开放源代码
You can visit the project homepage at https://ams.pluv27.top for user guides, development documentation and software updates, and participate in software development and improvement.
开发人员:
Open source under the GNU Affero General Public License (version 3), with an additional exemption clause for local API calls, used for other frontend to library interface calls.
- Wang Zhiyu([@pluvium27](https://github.com/pluvium27)): 项目作者
You are using the built-in terminal user interface, which is the first full-featured frontend implementation and library test suite, located in the interface subdirectory of the library.
特别感谢:
Developers:
- Wang Zhiyu ([@pluvium27](https://github.com/pluvium27)): Project initiator and lead developer
- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SuperMemo-2 算法
- [Thoughts Memo](https://www.zhihu.com/people/L.M.Sherlock): 文献参考
Special thanks to the following individuals and groups; their algorithms and theories form the cornerstone of the current software algorithms:
# 参与贡献
- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2 algorithm and SM-15 algorithm theory
- [Jarrett Ye](https://github.com/L-M-Sherlock): FSRS algorithm and spaced repetition theory references
- [Kazuaki Tanida](https://github.com/slaypni): CoffeeScript reverse implementation of SM-15 algorithm
- [Open Spaced Repetition](https://github.com/open-spaced-repetition): FSRS algorithm underlying implementation
我们是一个年轻且包容的社区, 由技术人员, 设计师, 文书工作者, 以及创意人员共同构成,
# Runtime Environment
通过我们协力开发的软件为所有人谋取福祉.
Python interpreter version: {python_version}
Python interpreter path: {executable}
Textual framework version: {textual_version}
Terminal emulator: {terminal_info}
Operating system version: {os_version}
Disk free space: {disk_usage}
上述工作不可避免地让我们确立了下列价值观 (取自 KDE 宣言):
- 开放治理 确保更多人能参与我们的领导和决策进程;
- 自由软件 确保我们的工作成果随时能为所有人所用;
- 多样包容 确保所有人都能加入社区并参加工作;
- 创新精神 确保新思路能不断涌现并服务于所有人;
- 共同产权 确保我们能团结一致;
- 迎合用户 确保我们的成果对所有人有用.
综上所述, 在为我们共同目标奋斗的过程中, 我们认为上述价值观反映了我们社区的本质, 是我们始终如一地保持初心的关键所在.
这是一项立足于协作精神的事业, 它的运作和产出不受任何单一个人或者机构的操纵.
我们的共同目标是为人人带来高品质的辅助记忆 & 学习软件.
不管您来自何方, 我们都欢迎您加入社区并做出贡献.
"""
# """
# 学术数据
# "潜进" 的用户数据可用于科学方面的研究, 我们将在未来版本添加学术数据的收集和展示平台
# """
When reporting issues, please copy this information into the issue description and attach `heurams.log` as an attachment to help developers locate the error."""
).format(
ver=version.ver,
codename=version.codename.capitalize(),
python_version=python_version,
executable=sys.executable,
textual_version=textual_version,
terminal_info=terminal_info,
os_version=os_version,
disk_usage=disk_usage,
)
yield Markdown(about_text, classes="about-markdown")
yield Button(
"返回主界面",
_("Back to Main"),
id="back_button",
variant="primary",
flat=True,
classes="back-button",
)
yield Footer()
@@ -92,3 +110,59 @@ class AboutScreen(Screen):
event.stop()
if event.button.id == "back_button":
self.action_go_back()
def _get_textual_version(self) -> str:
try:
import textual
return textual.__version__
except (ImportError, AttributeError):
return _("Unknown")
def _get_terminal_info(self) -> str:
terminal = shutil.which("terminal")
if terminal:
return terminal
# Try from environment variables
terminal_env = os.environ.get("TERM_PROGRAM") or os.environ.get("TERM")
return terminal_env or _("Unknown")
def _get_python_version(self) -> str:
"""获取 Python 解释器版本"""
return platform.python_version()
def _get_os_version(self) -> str:
"""获取操作系统版本"""
try:
if platform.system() == "Darwin":
# macOS
import subprocess
result = subprocess.run(
["sw_vers", "-productVersion"], capture_output=True, text=True
)
return f"macOS {result.stdout.strip()}"
elif platform.system() == "Windows":
# Windows
return f"Windows {platform.release()}"
elif platform.system() == "Linux":
# Linux - 尝试获取发行版信息
try:
import distro
return f"{distro.name()} {distro.version()}"
except (ImportError, AttributeError):
return platform.platform()
else:
return platform.platform()
except Exception:
return platform.platform()
def _get_disk_usage(self) -> str:
"""获取磁盘使用情况"""
usage = shutil.disk_usage("/")
free_gb = usage.free / (1024**3)
total_gb = usage.total / (1024**3)
percent_free = (free_gb / total_gb) * 100
#print(f"{free_gb:.1f} GB ({percent_free:.1f}%)")
return f"{free_gb:.1f} GB ({percent_free:.1f}%)"
+206 -118
View File
@@ -1,153 +1,241 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
ListView,
ListItem,
Button,
Static,
)
from textual.containers import ScrollableContainer
from textual.screen import Screen
"""Dashboard screen"""
from heurams.kernel.particles import *
from heurams.context import *
import heurams.services.version as version
from functools import reduce
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import ScrollableContainer, Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Label, ListItem, ListView, Static
from textual import events, on
from textual.reactive import reactive
import heurams.kernel.particles as pt
import heurams.services.timer as timer
from .preparation import PreparationScreen
from .about import AboutScreen
import heurams.services.version as version
from heurams.context import *
from heurams.i18n import _
from heurams.kernel.particles import *
from heurams.kernel.repolib import *
from heurams.services.logger import get_logger
import pathlib
from .navigator import NavigatorScreen
from .preparation import PreparationScreen
logger = get_logger(__name__)
class DashboardScreen(Screen):
SUB_TITLE = "仪表盘"
"""Main dashboard screen"""
SUB_TITLE = _("Dashboard")
BINDINGS = [
("q", "go_back", _("Back")),
]
CSS_PATH = rootdir / "interface" / "css" / "screens" / "dashboard.tcss"
repolink = reactive({})
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self._load_data()
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield ScrollableContainer(
Label(f'欢迎使用 "潜进" 启发式辅助记忆调度器', classes="title-label"),
Label(f"当前 UNIX 日时间戳: {timer.get_daystamp()}"),
Label(f'时区修正: UTC+{config_var.get()["timezone_offset"] / 3600}'),
Label("选择待学习或待修改的记忆单元集:", classes="title-label"),
ListView(id="union-list", classes="union-list-view"),
Label(
f'"潜进" 启发式辅助记忆调度器 | 版本 {version.ver} {version.codename.capitalize()} 2025'
),
)
"""Compose UI components"""
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer():
yield Horizontal(
Vertical(
Label(_("Current daystamp: {ds}").format(ds=timer.get_daystamp())),
Label(
_("Timezone offset: UTC+{offset}").format(offset=str(config_var.get()['services']['timer']['timezone_offset'] / 3600).removesuffix('.0'))
),
Label(
_("Default algorithm: {algo}").format(algo=config_var.get()['interface']['global']['algorithm']),
),
classes="left",
),
Vertical(
Label(_("Loaded {n} repo(s)").format(n=len(self.repos))),
Label(
_("Total {n} unit(s)").format(n=reduce(lambda x, y: x + y, map(lambda x: x.progress['total'], self.repos)) if self.repos else 0)
),
Label(
_("Activated {n} unit(s)").format(n=reduce(lambda x, y: x + y, map(lambda x: x.progress['touched'], self.repos)) if self.repos else 0)
),
Label(f""),
classes="right",
),
id="header",
)
yield ListView(id="repo_list", classes="repo-list")
from heurams.services.attic import Attic
a = Attic("ana", {"totaltime": 0, "openpuzzles": 0, "puzzles_err": 0})
yield Label(_("Version {ver}-{stage}").format(ver=version.ver, stage=version.stage))
yield Label(
_("Processed {puzzles} puzzles in {time}s, accuracy {accuracy}, speed {speed} puzzle(s)/s").format(
puzzles=a.data['openpuzzles'],
time=round(a.data['totaltime'], 2),
accuracy=_("N/A") if not a.data['openpuzzles'] else str(round(100 * (1 - a.data['puzzles_err']/a.data['openpuzzles']), 2)) + '%',
speed=_("N/A") if not a.data['totaltime'] else str(round(a.data['openpuzzles']/a.data['totaltime'], 2)),
),
id="analysis",
) # Version info
yield Footer()
def item_desc_generator(self, filename) -> dict:
"""简单分析以生成项目项显示文本
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
Returns:
dict: 以数字为列表, 分别呈现单行字符串
"""
res = dict()
filestem = pathlib.Path(filename).stem
res[0] = f"{filename}\0"
from heurams.kernel.particles.loader import load_electron
import heurams.kernel.particles as pt
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
# https://github.com/Textualize/textual/discussions/4268
# self.refresh(recompose=True) 此函数有问题且官方不管 而且性能低
electron_file_path = pathlib.Path(config_var.get()["paths"]["electron_dir"]) / (
filestem + ".json"
def _load_data(self):
repo_dirs = Repo.probe_valid_repos_in_dir(
Path(config_var.get()["global"]["paths"]["repo"])
)
self.repos = list(map(Repo.from_repodir, repo_dirs))
for repo in self.repos:
self._analyse_repo(repo)
def _analyse_repo(self, repo: Repo):
# need_review: 需要/不需要学习
# nearest_review_time: 最近下次学习时间
# progress: 进度
## initial_time: 起始时间
# package: 包名
# prompt: 最终呈现信息
repo.package = repo.manifest["package"]
repo.nearest_review_time = float("inf")
repo.progress = {
"total": repo.data_length,
"touched": 0,
"have_activated_ever": False,
}
repo.preview = {
"review": 0,
"new": repo.config[
"scheduled_num"
], # TODO: 考虑之后在这里加点运算避免 SM-2 积压, 但现在需要的是直观!
}
initial_time = float("inf")
for i in range(
repo.data_length
): # TODO: 增加异步性能优化, 但是学习数据属实规模小...
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict[i],
algo_name=repo.config["algorithm"],
)
# n = pt.Nucleon.from_data(repo.nucleonic_data_lict[i])
if e.is_activated():
repo.progress["have_activated_ever"] = True # 被激活过~
repo.progress["touched"] += 1
repo.nearest_review_time = min(repo.nearest_review_time, e.nextdate())
if timer.get_daystamp() >= e.nextdate():
repo.preview["review"] += 1
# initial_time = min(initial_time, e.)
repo.need_review = timer.get_daystamp() >= repo.nearest_review_time
repo.prompt = _(
"""{title} [{algo}]
[d]Progress: {touched}/{total} ({pct}%)[/d]
[d]{status}[/d]"""
).format(
title=repo.manifest["title"],
algo=repo.config["algorithm"],
touched=repo.progress["touched"],
total=repo.progress["total"],
pct=round(repo.progress["touched"] / repo.progress["total"] * 100, 1),
status=(
_("Due: {review}R + {new}U").format(
review=repo.preview["review"], new=repo.preview["new"]
)
if repo.need_review
else (
_("Not started: 0R + {new}U").format(new=repo.preview["new"])
if not repo.progress["have_activated_ever"]
else _("Up to date")
)
),
)
logger.debug(f"电子文件路径: {electron_file_path}")
if electron_file_path.exists(): # 未找到则创建电子文件 (json)
pass
else:
electron_file_path.touch()
with open(electron_file_path, "w") as f:
f.write("{}")
electron_dict = load_electron(path=electron_file_path) # TODO: 取消硬编码扩展名
logger.debug(electron_dict)
is_due = 0
is_activated = 0
nextdate = 0x3F3F3F3F
for i in electron_dict.values():
i: pt.Electron
logger.debug(i, i.is_due())
if i.is_due():
is_due = 1
if i.is_activated():
is_activated = 1
nextdate = min(nextdate, i.nextdate())
res[1] = f"下一次复习: {nextdate}\n"
res[1] += f"{is_due if "需要复习" else "当前无需复习"}"
if not is_activated:
res[1] = " 尚未激活"
return res
def on_mount(self) -> None:
union_list_widget = self.query_one("#union-list", ListView)
"""挂载组件时初始化"""
repo_list_widget = self.query_one("#repo_list", ListView)
probe = probe_all(0)
# 按下次复习时间排序
repodirs = sorted(
self.repos,
key=lambda r: r.nearest_review_time,
reverse=True, # 紧张的先复习
)
if len(probe["nucleon"]):
for file in probe["nucleon"]:
text = self.item_desc_generator(file)
union_list_widget.append(
ListItem(
Label(text[0] + "\n" + text[1]),
)
)
else:
union_list_widget.append(
# 填充列表
if not repodirs:
repo_list_widget.append(
ListItem(
Static(
"在 ./nucleon/ 中未找到任何内容源数据文件.\n请放置文件后重启应用.\n或者新建空的单元集."
)
_("No repo directories found in {path}.\nPlease import a repo and restart, or create a new one.").format(path=config_var.get()['global']['paths']['repo'])
),
id="not-found",
)
)
union_list_widget.disabled = True
repo_list_widget.disabled = True
return
for r in self.repos:
self.repolink[str(r.manifest["package"])] = r # 用于规避 ctype id 对象还原
# NOTE: 上一行不要使用 id(), id 可能被重用!
list_item = ListItem(
*[Label(line) for line in r.prompt.splitlines()],
Button(
_("Start Learning"),
flat=True,
variant="primary",
id=f"slaunch_repo_{r.manifest['package']}",
classes="repo-list-item-shortcut",
),
classes="repo-list-item",
id=f"launch_repo_{r.manifest['package']}",
)
repo_list_widget.append(list_item)
def on_list_view_selected(self, event) -> None:
"""处理列表项选择事件"""
if not isinstance(event.item, ListItem):
return
selected_label = event.item.query_one(Label)
if "未找到任何 .toml 文件" in str(selected_label.renderable): # type: ignore
if "not-found" == event.item.id:
return
selected_filename = pathlib.Path(
str(selected_label.renderable)
.partition("\0")[0] # 文件名末尾截断, 保留文件名
.replace("*", "")
) # 去除markdown加粗
# 还原对象
selected_repo = self.repolink[event.item.id.removeprefix("launch_repo_")]
nucleon_file_path = (
pathlib.Path(config_var.get()["paths"]["nucleon_dir"]) / selected_filename
)
electron_file_path = pathlib.Path(config_var.get()["paths"]["electron_dir"]) / (
str(selected_filename.stem) + ".json"
)
self.app.push_screen(PreparationScreen(nucleon_file_path, electron_file_path))
def on_button_pressed(self, event) -> None:
if event.button.id == "new_nucleon_button":
# 切换到创建单元
from .nucreator import NucleonCreatorScreen
newscr = NucleonCreatorScreen()
self.app.push_screen(newscr)
elif event.button.id == "precache_all_button":
# 切换到缓存管理器
from .precache import PrecachingScreen
precache_screen = PrecachingScreen()
self.app.push_screen(precache_screen)
elif event.button.id == "about_button":
from .about import AboutScreen
about_screen = AboutScreen()
self.app.push_screen(about_screen)
# 跳转到准备屏幕
self.app.push_screen(PreparationScreen(selected_repo))
def action_quit_app(self) -> None:
"""退出应用程序"""
self.app.exit()
def action_open_navigator(self) -> None:
"""打开导航器"""
self.app.push_screen(NavigatorScreen())
def on_button_pressed(self, event: Button.Pressed) -> None:
"""处理按钮点击事件"""
if event.button.id.startswith("slaunch_repo_"): # type: ignore
from .preparation import launch
launch(repo=self.repolink[event.button.id.removeprefix("slaunch_repo_")], app=self.app, scheduled_num=-1) # type: ignore
# TODO: 这样启动的记忆实例的状态机无法绑定到 PreparationScreen 中
+220
View File
@@ -0,0 +1,220 @@
"""Favorites manager screen"""
import base64
from pathlib import Path
from typing import List, Optional
from textual import events, on
from textual.app import ComposeResult
from textual.containers import ScrollableContainer, Horizontal
from textual.screen import Screen
from textual.widgets import (
Button,
Footer,
Header,
Label,
ListItem,
ListView,
Static,
)
from textual import events, on
from heurams.context import config_var
from heurams.i18n import _
from heurams.kernel.repolib import Repo
from heurams.services.favorite_service import FavoriteItem, favorite_manager
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class FavoriteManagerScreen(Screen):
"""Favorites manager screen"""
SUB_TITLE = _("Favorites")
BINDINGS = [
("q", "go_back", _("Back")),
("d", "toggle_dark", ""),
]
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self.favorites: List[FavoriteItem] = []
self._load_favorites()
def _load_favorites(self) -> None:
"""Load favorites list"""
self.favorites = favorite_manager.get_all()
logger.info("Loaded %d favorites", len(self.favorites))
def compose(self) -> ComposeResult:
"""Compose UI components"""
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="favorites-container"):
if not self.favorites:
yield Label(_("No favorites"), classes="empty-label")
yield Static(_("Press * in the memorization screen to add favorites."))
else:
yield Label(_("Total {n} favorite(s)").format(n=len(self.favorites)), classes="count-label")
yield ListView(id="favorites-list")
yield Footer()
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def on_mount(self) -> None:
"""挂载后填充列表"""
if self.favorites:
list_view = self.query_one("#favorites-list")
for fav in self.favorites:
list_view.append(self._create_favorite_item(fav)) # type: ignore
def _encode_favorite_key(self, repo_path: str, ident: str) -> str:
"""Encode repo path and identifier into a safe button ID part"""
# Use \x00 as separator between the two parts, then base64 encode
combined = f"{repo_path}\x00{ident}"
encoded = base64.urlsafe_b64encode(combined.encode()).decode()
# Strip padding
return encoded.rstrip("=")
def _decode_favorite_key(self, key: str) -> tuple[str, str]:
"""Decode button ID part back to repo path and identifier"""
# Pad to make length multiple of 4
padded = key + "=" * ((4 - len(key) % 4) % 4)
decoded = base64.urlsafe_b64decode(padded.encode()).decode()
repo_path, ident = decoded.split("\x00", 1)
return repo_path, ident
def _create_favorite_item(self, fav: FavoriteItem) -> ListItem:
"""Create a favorite list item"""
# Try to get repo info
repo_info = self._get_repo_info(fav.repo_path, fav)
title = repo_info.get("title", fav.repo_path) if repo_info else fav.repo_path
added_time = self._format_time(fav.added)
# Build display text
display_text = f"{fav.ident}\n"
display_text += _(" [d]Added: {time}\n From {title}[/d]").format(time=added_time, title=title)
if fav.tags:
display_text += f"{', '.join(fav.tags)}"
# Create safe button ID
button_key = self._encode_favorite_key(fav.repo_path, fav.ident)
# Create list item with remove button
container = Horizontal(
Label(display_text, classes="favorite-content"),
Button(
_("Remove"),
id=f"remove-{button_key}",
variant="error",
flat=True,
classes="favorite-item-btn",
),
classes="favorite-item",
)
return ListItem(container)
def _get_repo_info(self, repo_path: str, fav: FavoriteItem) -> Optional[dict]:
"""Get repo info (title, atom content preview)"""
try:
data_repo = Path(config_var.get()["global"]["paths"]["data"]) / "repo"
repo_dir = data_repo / repo_path
if not repo_dir.exists():
logger.warning("Repo directory does not exist: %s", repo_dir)
return None
repo = Repo.from_repodir(repo_dir)
# Get atom content preview
content_preview = ""
payload = repo.payload
# Find the payload entry matching ident
for ident_key, content in payload:
if ident_key == fav.ident:
# Truncate long content
if isinstance(content, dict) and "content" in content:
text = content["content"]
else:
text = str(content)
if len(text) > 100:
content_preview = text[:100] + "..."
else:
content_preview = text
break
return {
"title": repo.manifest["title"],
"content_preview": content_preview,
}
except Exception as e:
logger.error("Failed to get repo info: %s", e)
return None
def _format_time(self, timestamp: int) -> str:
"""Format timestamp as datetime string"""
from datetime import datetime
dt = datetime.fromtimestamp(timestamp)
return dt.strftime("%Y-%m-%d %H:%M")
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button press event"""
button_id = event.button.id
if button_id and button_id.startswith("remove-"):
# Extract encoded key
key = button_id[7:] # Remove "remove-" prefix
try:
repo_path, ident = self._decode_favorite_key(key)
self._remove_favorite(repo_path, ident)
except Exception as e:
logger.error("Failed to parse button ID: %s", e)
self.app.notify(_("Operation failed: invalid button identifier"), severity="error")
def _remove_favorite(self, repo_path: str, ident: str) -> None:
"""Remove a favorite item"""
if favorite_manager.remove(repo_path, ident):
self.app.notify(_("Removed favorite: {ident}").format(ident=ident), severity="information")
# Reload list
self._load_favorites()
# Refresh UI
self._refresh_list()
else:
self.app.notify(_("Failed to remove: {ident}").format(ident=ident), severity="error")
def _refresh_list(self) -> None:
"""Refresh the list display"""
container = self.query_one("#favorites-container")
# Clear container
for child in container.children:
child.remove()
# Re-compose
if not self.favorites:
container.mount(Label(_("No favorites"), classes="empty-label"))
container.mount(Static(_("Press * in the memorization screen to add favorites.")))
else:
container.mount(
Label(_("Total {n} favorite(s)").format(n=len(self.favorites)), classes="count-label")
)
list_view = ListView(id="favorites-list")
container.mount(list_view)
for fav in self.favorites:
list_view.append(self._create_favorite_item(fav))
def action_go_back(self) -> None:
"""返回上一屏幕"""
self.app.pop_screen()
def action_toggle_dark(self) -> None:
"""切换暗黑模式"""
self.app.dark = not self.app.dark # type: ignore
@@ -0,0 +1 @@
"""整体式记忆工作界面"""
+289
View File
@@ -0,0 +1,289 @@
"""Queue-based memorization screen"""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import ScrollableContainer
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import Footer, Header, Label, Static
from textual import events, on
import heurams.kernel.particles as pt
from heurams.context import config_var, rootdir
from heurams.i18n import _
from heurams.kernel.reactor import *
from heurams.services.favorite_service import favorite_manager
from heurams.services.logger import get_logger
from heurams.services.attic import Attic
from .. import shim
logger = get_logger(__name__)
class MemScreen(Screen):
BINDINGS = [
("q", "go_back_notif", _("Back")),
("p", "prev", _("Previous")),
("d", "toggle_dark", ""),
("v", "play_voice", _("Read Aloud")),
("*", "toggle_favorite", _("Favorite")),
("r", "resume_mark"),
("Q", "go_back"),
("n", "block_prompt"),
("s", "block_prompt"),
("z", "block_prompt"),
]
SUB_TITLE = _("Learning")
CSS_PATH = rootdir / "interface" / "css" / "screens" / "memoqueue.tcss"
if config_var.get()["interface"]["global"]["quick_pass"]:
BINDINGS.append(("k", "quick_pass", _("Correct")))
BINDINGS.append(("f", "quick_fail", _("Incorrect")))
rating = reactive(-1)
def __init__(
self,
router: Router,
repo=None,
name=None,
id=None,
classes=None,
) -> None:
super().__init__(name, id, classes)
self.router = router
self.repo = repo
self.update_state()
self.expander: Expander
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult:
from heurams.services.attic import Attic
a = Attic("ana", {"openqueue": 0})
a.data["openqueue"] += 1
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(classes="memoqueue-container"):
yield Label(self._get_progress_text(), id="head_stat")
yield ScrollableContainer(id="puzzle_container")
yield Footer()
def update_state(self):
"""Update state machine"""
self.procession: Procession = self.router.current_procession() # type: ignore
self.atom: pt.Atom = self.procession.current_atom # type: ignore
def on_mount(self):
self.expander = self.procession.get_expander()
from heurams.services.attic import Attic
import time
a = Attic("ana", {"last": time.time()})
a.data["last"] = time.time()
self.mount_puzzle()
self.update_display()
def puzzle_widget(self):
try:
puzzle = self.expander.get_current_puzzle_inf()
return shim.puzzle2widget[puzzle["puzzle"]]( # type: ignore
atom=self.atom, alia=puzzle["alia"] # type: ignore
)
except Exception as e:
logger.error(f"Failed to expand puzzle: {e}")
return Static(_("Failed to generate puzzle: {e}").format(e=e))
def _get_progress_text(self):
s = ""
if self.repo is not None:
fav_status = _("Favorited") if self._is_current_atom_favorited() else _("Not favorited")
s += f"[{fav_status}] "
s += f"[{self.procession.process() + 1}/{self.procession.total_length()}] \\[{self.procession.route.name}]\n"
if self.procession.cursor - 1 >= 0:
s += _("Previous: {ident}").format(ident=f"[d]{self.procession.atoms[self.procession.cursor - 1]['ident']}[/d]")
return s
def update_display(self):
"""更新进度显示"""
progress_widget = self.query_one("#head_stat")
progress_widget.update(self._get_progress_text()) # type: ignore
def mount_puzzle(self):
"""Mount current puzzle widget"""
if self.procession.route == RouterState.FINISHED:
self.mount_finished_widget()
return
container = self.query_one("#puzzle_container")
for i in container.children:
i.remove()
container.mount(self.puzzle_widget())
def mount_finished_widget(self):
"""Mount finished widget"""
a = Attic("ana", {"finished": 0})
a.data["finished"] += 1
container = self.query_one("#puzzle_container")
for i in container.children:
i.remove()
from heurams.interface.widgets.finished import Finished
if config_var.get()["interface"]["global"]["persist_to_file"]:
self.repo.persist_to_repodir()
container.mount(
Finished(
is_saved=config_var.get()["interface"]["global"]["persist_to_file"]
)
)
def on_button_pressed(self, event):
event.stop()
def action_play_voice(self):
self.run_worker(self.play_voice, exclusive=True, thread=True)
def play_voice(self):
"""Read current content aloud"""
from pathlib import Path
from heurams.services.audio_service import play_by_path
from heurams.services.hasher import get_md5
path = Path(config_var.get()["global"]["paths"]["data"]) / "cache" / "voice"
path = path / f"{get_md5(self.atom.registry['nucleon']["tts_text"])}.wav"
if path.exists():
play_by_path(path)
else:
from heurams.services.tts_service import convertor
convertor(self.atom.registry["nucleon"]["tts_text"], path)
play_by_path(path)
def watch_rating(self, old_rating, new_rating) -> None:
if new_rating == -1: # 安全值
return
self.update_state()
if self.procession.route == RouterState.FINISHED:
rating = -1
return
self.expander.report(new_rating)
self.forward(new_rating)
self.rating = -1
def forward(self, rating):
self.update_state()
allow_forward = 1 if rating >= 4 else 0
if allow_forward:
self.expander.forward()
if self.expander.state == "retronly":
self.forward_atom(self.expander.get_quality())
self.update_state()
from heurams.services.attic import Attic
a = Attic("ana", {"openpuzzles": 0})
a = Attic("ana", {"totaltime": 0})
a.data["openpuzzles"] += 1
import time
a.data["totaltime"] += time.time() - a.data["last"]
a.data["last"] = time.time()
self.mount_puzzle()
self.update_display()
def atom_reporter(self, quality):
if not self.atom.registry["runtime"]["locked"]:
if not self.atom.registry["electron"].is_activated():
self.atom.registry["electron"].activate()
logger.debug(f"Activated atom: {self.atom}")
self.atom.lock(1)
self.atom.minimize(5)
else:
self.atom.minimize(quality)
else:
pass
def forward_atom(self, quality):
logger.debug(f"Quality: {quality}")
self.atom_reporter(quality)
if quality <= 3:
from heurams.services.attic import Attic
a = Attic("ana", {"puzzles_err": 0})
a.data["puzzles_err"] += 1
self.procession.append()
self.update_state() # 刷新状态
self.procession.forward(1)
self.update_state() # 刷新状态
self.expander = self.procession.get_expander()
def action_go_back_notif(self):
self.notify(_("Are you sure? Press uppercase Q to go back."))
def action_go_back(self):
self.app.pop_screen()
def action_quick_pass(self):
self.rating = 5
def action_quick_fail(self):
self.rating = 3
def _get_repo_rel_path(self) -> str:
"""Get repo relative path (relative to data/repo)"""
if self.repo is None:
return ""
# self.repo.source is the Path object pointing to the repo directory
repo_full_path = self.repo.source
data_repo_path = Path(config_var.get()["global"]["paths"]["data"]) / "repo"
try:
rel_path = repo_full_path.relative_to(data_repo_path)
return str(rel_path)
except ValueError:
# If not under data/repo, return the full path as string
return str(repo_full_path)
def _is_current_atom_favorited(self) -> bool:
"""Check if current atom is favorited"""
if self.repo is None:
return False
repo_path = self._get_repo_rel_path()
return favorite_manager.has(repo_path, self.atom.ident)
def action_toggle_favorite(self):
"""Toggle favorite status"""
if self.repo is None:
self.app.notify(_("Cannot favorite: no repo associated"), severity="error")
return
repo_path = self._get_repo_rel_path()
ident = self.atom.ident
if favorite_manager.has(repo_path, ident):
favorite_manager.remove(repo_path, ident)
self.app.notify(_("Unfavorited: {ident}").format(ident=ident), severity="information")
else:
favorite_manager.add(repo_path, ident)
self.app.notify(_("Favorited: {ident}").format(ident=ident), severity="information")
# Update display if needed
self.update_display()
def action_block_prompt(self):
self.app.notify(_("This function is not available during memorization. Please finish or go back first."), severity="error")
def action_resume_mark(self):
from heurams.services.attic import Attic
import time
a = Attic("ana")
l = a.data["last"]
a.data["last"] = time.time()
self.app.notify(_("Time resume corrected: {old} -> {new}").format(old=l, new=a.data['last']))
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import Header, Footer, Label, Static, Button
from textual.containers import Center, ScrollableContainer
from textual.screen import Screen
from textual.reactive import reactive
from enum import Enum, auto
from heurams.services.logger import get_logger
from heurams.context import config_var
from heurams.kernel.reactor import *
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from .. import shim
class AtomState(Enum):
FAILED = auto()
NORMAL = auto()
logger = get_logger(__name__)
class MemScreen(Screen):
BINDINGS = [
("q", "pop_screen", "返回"),
# ("p", "prev", "复习上一个"),
("d", "toggle_dark", ""),
("v", "play_voice", "朗读"),
("0,1,2,3", "app.push_screen('about')", ""),
]
if config_var.get()["quick_pass"]:
BINDINGS.append(("k", "quick_pass", "跳过"))
rating = reactive(-1)
def __init__(
self,
atoms: list,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self.atoms = atoms
self.phaser = Phaser(atoms)
# logger.debug(self.phaser.state)
self.procession: Procession = self.phaser.current_procession() # type: ignore
self.atom: pt.Atom = self.procession.current_atom
# logger.debug(self.phaser.state)
# self.procession.forward(1)
for i in atoms:
i.do_eval()
def on_mount(self):
self.load_puzzle()
pass
def puzzle_widget(self):
try:
logger.debug(self.phaser.state)
logger.debug(self.procession.cursor)
logger.debug(self.atom)
self.fission = Fission(self.atom, self.phaser.state)
puzzle_debug = next(self.fission.generate())
# logger.debug(puzzle_debug)
return shim.puzzle2widget[puzzle_debug["puzzle"]](
atom=self.atom, alia=puzzle_debug["alia"]
)
except (KeyError, StopIteration, AttributeError) as e:
logger.debug(f"调度展开出错: {e}")
return Static("无法生成谜题")
# logger.debug(shim.puzzle2widget[puzzle_debug["puzzle"]])
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer():
yield Label(self._get_progress_text(), id="progress")
# self.mount(self.current_widget()) # type: ignore
yield ScrollableContainer(id="puzzle-container")
# yield Button("重新学习此单元", id="re-recognize", variant="warning")
yield Footer()
def _get_progress_text(self):
return f"当前进度: {self.procession.process() + 1}/{self.procession.total_length()}"
def update_display(self):
progress_widget = self.query_one("#progress")
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()
container.mount(self.puzzle_widget())
def load_finished_widget(self):
container = self.query_one("#puzzle-container")
for i in container.children:
i.remove()
from heurams.interface.widgets.finished import Finished
container.mount(Finished())
def on_button_pressed(self, event):
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
self.rating = -1
logger.debug(f"试图前进: {"允许" if forwards else "禁止"}")
if forwards:
ret = self.procession.forward(1)
if ret == 0: # 若结束了此次队列
self.procession = self.phaser.current_procession() # type: ignore
if self.procession == 0: # 若所有队列都结束了
logger.debug(f"记忆进程结束")
for i in self.atoms:
i: pt.Atom
i.revise()
i.persist("electron")
self.load_finished_widget()
return
else:
logger.debug(f"建立新队列 {self.procession.phase}")
self.load_puzzle()
else: # 若不通过
self.procession.append()
self.update_display()
def action_quick_pass(self):
self.rating = 5
self.atom.minimize(5)
self.atom.registry["electron"].activate()
self.atom.lock(1)
def action_play_voice(self):
"""朗读当前内容"""
pass
def action_toggle_dark(self):
self.app.action_toggle_dark()
def action_pop_screen(self):
self.app.pop_screen()
@@ -0,0 +1,92 @@
from textual.app import ComposeResult
from textual.containers import Grid
from textual.screen import ModalScreen
from textual.widgets import Button, Label, ListItem, ListView, Static
from heurams.i18n import _
from heurams.services.logger import get_logger
from .favmgr import FavoriteManagerScreen
logger = get_logger(__name__)
class NavigatorScreen(ModalScreen):
"""Navigator modal screen"""
BINDINGS = [
("q", "go_back", _("Back")),
("escape", "go_back", _("Back")),
("n", "go_back", _("Switch")),
]
SCREENS = [
(_("Dashboard"), "dashboard"),
(_("Cache Manager"), "precache_all"),
(_("Favorites"), FavoriteManagerScreen),
(_("Settings Page"), "setting"),
(_("Sync Tool"), "synctool"),
(_("About"), "about"),
]
OTHERS = [
(_("Exit"), "self.app.exit()"),
(_("Project Homepage"), "webbrowser.open('https://ams.pluv27.top')"),
]
def compose(self) -> ComposeResult:
"""Compose UI components"""
with Grid(id="dialog"):
yield Label(
_("[b]Select a function to navigate to\nor a memorization session instance[/b]\n\nTips will be displayed here"),
classes="title-label",
)
yield ListView(
*[ListItem(Label(title)) for title, _ in (self.SCREENS + self.OTHERS)],
id="nav-list",
classes="nav-list-view",
)
yield Static(_("Press Enter to switch\nAll sessions will be saved"))
yield Button(
_("Close (n)"),
id="close_button",
variant="primary",
classes="close-button",
flat=True,
)
def on_mount(self) -> None:
# 设置焦点到列表
nav_list = self.query_one("#nav-list", ListView)
nav_list.focus()
def on_list_view_selected(self, event) -> None:
if not isinstance(event.item, ListItem):
return
selected_label = event.item.query_one(Label)
label_text = str(selected_label.render())
# 查找对应的屏幕标识
for title, screen_id in self.SCREENS:
if title == label_text:
self.app.pop_screen()
# 跳转到目标屏幕
if isinstance(screen_id, str):
# 已注册的字符串标识符
self.app.push_screen(screen_id)
else:
self.app.push_screen(screen_id())
return
for title, cmd in self.OTHERS:
if title == label_text:
exec(cmd)
return
return
def on_button_pressed(self, event) -> None:
event.stop()
if event.button.id == "close_button":
self.action_go_back()
def action_go_back(self) -> None:
self.app.pop_screen()
-171
View File
@@ -1,171 +0,0 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Input,
Select,
Button,
Markdown,
)
from textual.containers import ScrollableContainer
from textual.screen import Screen
from heurams.services.version import ver
import toml
from pathlib import Path
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)
def search_templates(self):
from pathlib import Path
from heurams.context import config_var
template_dir = Path(config_var.get()["paths"]["template_dir"])
templates = list()
for i in template_dir.iterdir():
if i.name.endswith(".toml"):
try:
import toml
with open(i, "r") as f:
dic = toml.load(f)
desc = dic["__metadata__.attribution"]["desc"]
templates.append(desc + " (" + i.name + ")")
except Exception as e:
templates.append(f"无描述模板 ({i.name})")
print(e)
return templates
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer(id="vice_container"):
yield Label(f"[b]空白单元集创建向导\n")
yield Markdown(
"> 提示: 你可能注意到当选中文本框时底栏和操作按键绑定将被覆盖 \n只需选中(使用鼠标或 Tab)选择框即可恢复底栏功能"
)
yield Markdown("1. 键入单元集名称")
yield Input(placeholder="单元集名称", id="name_input")
yield Markdown(
"> 单元集名称不应与现有单元集重复. \n> 新的单元集文件将创建在 ./nucleon/你输入的名称.toml"
)
yield Label(f"\n")
yield Markdown("2. 选择单元集模板")
LINES = self.search_templates()
"""带有宏支持的空白单元集 ({ver})
古诗词模板单元集 ({ver})
英语词汇和短语模板单元集 ({ver})
"""
yield Select.from_values(LINES, prompt="选择类型", id="template_select")
yield Markdown("> 新单元集的版本号将和主程序版本保持同步")
yield Label(f"\n")
yield Markdown("3. 输入常见附加元数据 (可选)")
yield Input(placeholder="作者", id="author_input")
yield Input(placeholder="内容描述", id="desc_input")
yield Button(
"新建空白单元集",
id="submit_button",
variant="primary",
classes="start-button",
)
yield Footer()
def on_mount(self):
self.query_one("#submit_button").focus()
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 == "submit_button":
# 获取输入值
name_input = self.query_one("#name_input")
template_select = self.query_one("#template_select")
author_input = self.query_one("#author_input")
desc_input = self.query_one("#desc_input")
name = name_input.value.strip() # type: ignore
author = author_input.value.strip() # type: ignore
desc = desc_input.value.strip() # type: ignore
selected = template_select.value # type: ignore
# 验证
if not name:
self.notify("单元集名称不能为空", severity="error")
return
# 获取配置路径
config = config_var.get()
nucleon_dir = Path(config["paths"]["nucleon_dir"])
template_dir = Path(config["paths"]["template_dir"])
# 检查文件是否已存在
nucleon_path = nucleon_dir / f"{name}.toml"
if nucleon_path.exists():
self.notify(f"单元集 '{name}' 已存在", severity="error")
return
# 确定模板文件
if selected is None:
self.notify("请选择一个模板", severity="error")
return
# selected 是描述字符串, 格式如 "描述 (filename.toml)"
# 提取文件名
import re
match = re.search(r"\(([^)]+)\)$", selected)
if not match:
self.notify("模板选择格式无效", severity="error")
return
template_filename = match.group(1)
template_path = template_dir / template_filename
if not template_path.exists():
self.notify(f"模板文件不存在: {template_filename}", severity="error")
return
# 加载模板
try:
with open(template_path, "r", encoding="utf-8") as f:
template_data = toml.load(f)
except Exception as e:
self.notify(f"加载模板失败: {e}", severity="error")
return
# 更新元数据
metadata = template_data.get("__metadata__", {})
attribution = metadata.get("attribution", {})
if author:
attribution["author"] = author
if desc:
attribution["desc"] = desc
attribution["name"] = name
# 可选: 设置版本
attribution["version"] = ver
metadata["attribution"] = attribution
template_data["__metadata__"] = metadata
# 确保 nucleon_dir 存在
nucleon_dir.mkdir(parents=True, exist_ok=True)
# 写入新文件
try:
with open(nucleon_path, "w", encoding="utf-8") as f:
toml.dump(template_data, f)
except Exception as e:
self.notify(f"保存单元集失败: {e}", severity="error")
return
self.notify(f"单元集 '{name}' 创建成功")
self.app.pop_screen()
+205 -105
View File
@@ -1,36 +1,48 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Button,
Static,
ProgressBar,
)
from textual.containers import ScrollableContainer, Horizontal
from textual.containers import ScrollableContainer
from textual.screen import Screen
"""Cache tool screen"""
import pathlib
from textual.app import ComposeResult
from textual.containers import Horizontal, ScrollableContainer, Container
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Label, ProgressBar, Static
from textual.worker import get_current_worker
from textual import events, on
import heurams.kernel.particles as pt
import heurams.services.hasher as hasher
from heurams.context import *
from textual.worker import get_current_worker
from heurams.i18n import _
# Compatibility cache path: prefer paths.cache, otherwise data/cache
paths = config_var.get()["global"]["paths"]
cache_dir = pathlib.Path(paths.get("cache", paths["data"] + "/cache")) / "voice"
def human_size(bytes_num: int) -> str:
"""Format byte count as human-readable string"""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if bytes_num < 1024.0:
return f"{bytes_num:.2f} {unit}"
bytes_num /= 1024.0 # type: ignore
return f"{bytes_num:.2f} PB"
class PrecachingScreen(Screen):
"""预缓存音频文件屏幕
"""Audio file pre-caching screen
缓存记忆单元音频文件, 全部(默认) 或部分记忆单元(可选参数传入)
Cache memory unit audio files, all (default) or some memory units (optional params)
Args:
nucleons (list): 可选列表, 仅包含 Nucleon 对象
desc (list): 可选字符串, 包含对此次调用的文字描述
nucleons (list): Optional list containing Nucleon objects only
desc (list): Optional string containing description of this call
"""
SUB_TITLE = "缓存管理器"
BINDINGS = [("q", "go_back", "返回")]
SUB_TITLE = _("Cache Manager")
BINDINGS = [
("q", "go_back", _("Back")),
]
def __init__(self, nucleons: list = [], desc: str = ""):
super().__init__(name=None, id=None, classes=None)
@@ -44,90 +56,187 @@ class PrecachingScreen(Screen):
self.precache_worker = None
self.cancel_flag = 0
self.desc = desc
for i in nucleons:
i: pt.Nucleon
i.do_eval()
# print("完成 EVAL")
# 不再需要缓存配置, 保留配置读取以兼容
self.cache_stats = {
"total_size": 0,
"file_count": 0,
"human_size": "0 B",
"cached_units": 0,
"total_units": 0,
"cache_rate": 0,
}
self._update_cache_stats()
def _get_total_units(self) -> int:
"""Get total units across all repos"""
from heurams.context import config_var
from heurams.kernel.repolib import Repo
repo_path = pathlib.Path(config_var.get()["global"]["paths"]["data"]) / "repo"
repo_dirs = Repo.probe_valid_repos_in_dir(repo_path)
repos = map(Repo.from_repodir, repo_dirs)
total = 0
for repo in repos:
try:
total += len(repo.ident_index)
except:
continue
return total
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def _update_cache_stats(self) -> None:
"""Update cache statistics"""
total_size = 0
file_count = 0
cached_units = 0
if cache_dir.exists():
for file in cache_dir.rglob("*"):
if file.is_file():
total_size += file.stat().st_size
file_count += 1
if file.suffix.lower() == ".wav":
cached_units += 1
total_units = self._get_total_units()
cache_rate = (cached_units / total_units * 100) if total_units > 0 else 0
self.cache_stats["total_size"] = total_size
self.cache_stats["file_count"] = file_count
self.cache_stats["human_size"] = human_size(total_size)
self.cache_stats["cached_units"] = cached_units
self.cache_stats["total_units"] = total_units
self.cache_stats["cache_rate"] = cache_rate
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="precache_container"):
yield Label("[b]音频预缓存[/b]", classes="title-label")
if self.nucleons:
yield Static(f"目标单元归属: [b]{self.desc}[/b]", classes="target-info")
yield Static(f"单元数量: {len(self.nucleons)}", classes="target-info")
else:
yield Static("目标: 所有单元", classes="target-info")
yield Static(id="status", classes="status-info")
yield Static(id="current_item", classes="current-item")
yield ProgressBar(total=100, show_eta=False, id="progress_bar")
with Horizontal(classes="button-group"):
if not self.is_precaching:
yield Button("开始预缓存", id="start_precache", variant="primary")
yield Label(_("[b]Audio Pre-cache[/b]"), classes="title-label")
with Container():
yield Static(
_("Cache rate: {rate:.1f}% ({cached} / {total} units)").format(
rate=self.cache_stats.get('cache_rate', 0),
cached=self.cache_stats.get('cached_units', 0),
total=self.cache_stats.get('total_units', 0),
),
classes="cache-usage-text",
)
if self.nucleons:
yield Static(
_("Target units from: [b]{desc}[/b]").format(desc=self.desc), classes="target-info"
)
yield Static(
_("Unit count: {n}").format(n=len(self.nucleons)), classes="target-info"
)
else:
yield Button("取消预缓存", id="cancel_precache", variant="error")
yield Button("清空缓存", id="clear_cache", variant="warning")
yield Button("返回", id="go_back", variant="default")
yield Static(_("Target: all units"), classes="target-info")
yield Static("若您离开此界面, 未完成的缓存进程会自动停止.")
yield Static('缓存程序支持 "断点续传".')
yield Static(id="status", classes="status-info")
yield Static(id="current_item", classes="current-item")
yield ProgressBar(total=100, show_eta=False, id="progress_bar")
with Horizontal(classes="button-group"):
if not self.is_precaching:
yield Button(
_("Start Pre-cache"), id="start_precache", variant="primary"
)
else:
yield Button(
_("Cancel Pre-cache"), id="cancel_precache", variant="error"
)
yield Button(_("Clear Cache"), id="clear_cache", variant="warning")
yield Button(_("Back"), id="go_back", variant="default")
with Container(classes="cache-info"):
yield Static(_("Cache path: {path}").format(path=cache_dir), classes="cache-path")
yield Static(
_("Files: {n}").format(n=self.cache_stats['file_count']), classes="cache-count"
)
yield Static(
_("Total size: {size}").format(size=self.cache_stats['human_size']), classes="cache-size"
)
yield Button(
_("Refresh"), id="refresh_cache_stats", variant="default", flat=True
)
yield Static(_("If you leave this screen, ongoing cache processes will stop automatically."))
yield Static(_('Cache supports "resume from break".'))
yield Footer()
def on_mount(self):
"""挂载时初始化状态"""
self.update_status("就绪", "等待开始...")
"""Initialise state on mount"""
self.update_status(_("Ready"), _("Waiting to start..."))
self._update_cache_display()
def update_status(self, status, current_item="", progress=None):
"""更新状态显示"""
"""Update status display"""
status_widget = self.query_one("#status", Static)
item_widget = self.query_one("#current_item", Static)
progress_bar = self.query_one("#progress_bar", ProgressBar)
status_widget.update(f"状态: {status}")
item_widget.update(f"当前项目: {current_item}" if current_item else "")
status_widget.update(_("Status: {s}").format(s=status))
item_widget.update(_("Current item: {item}").format(item=current_item) if current_item else "")
if progress is not None:
progress_bar.progress = progress
progress_bar.advance(0) # 刷新显示
progress_bar.advance(0) # Refresh display
def _update_cache_display(self) -> None:
"""Update cache info display"""
# Update stats
self._update_cache_stats()
# Update cache rate progress bar
# Update cache size and file count display
cache_count_widget = self.query_one(".cache-count", Static)
cache_size_widget = self.query_one(".cache-size", Static)
cache_usage_text = self.query_one(".cache-usage-text", Static)
if cache_count_widget:
cache_count_widget.update(_("Files: {n}").format(n=self.cache_stats['file_count']))
if cache_size_widget:
cache_size_widget.update(_("Total size: {size}").format(size=self.cache_stats['human_size']))
if cache_usage_text:
cache_usage_text.update(
_("Cache rate: {rate:.1f}% ({cached} / {total} units)").format(
rate=self.cache_stats.get('cache_rate', 0),
cached=self.cache_stats.get('cached_units', 0),
total=self.cache_stats.get('total_units', 0),
)
)
def precache_by_text(self, text: str):
"""预缓存单段文本的音频"""
from heurams.context import rootdir, workdir, config_var
"""Pre-cache audio for a single text string"""
cache_dir = pathlib.Path(config_var.get()["paths"]["cache_dir"])
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / f"{hasher.get_md5(text)}.wav"
if not cache_file.exists():
try: # TODO: 调用模块消除tts耦合
import edge_tts as tts
try:
from heurams.services.tts_service import convertor
communicate = tts.Communicate(text, "zh-CN-XiaoxiaoNeural")
communicate.save_sync(str(cache_file))
convertor(text, cache_file)
return 1
except Exception as e:
print(f"预缓存失败 '{text}': {e}")
print(f"Pre-cache failed '{text}': {e}")
return 0
return 1
def precache_by_nucleon(self, nucleon: pt.Nucleon):
"""依据 Nucleon 缓存"""
# print(nucleon.metadata['formation']['tts_text'])
ret = self.precache_by_text(nucleon.metadata["formation"]["tts_text"])
"""Cache based on Nucleon"""
ret = self.precache_by_text(nucleon["tts_text"])
return ret
# print(f"TTS 缓存: {nucleon.metadata['formation']['tts_text']}")
def precache_by_list(self, nucleons: list):
"""依据 Nucleons 列表缓存"""
"""Cache based on Nucleons list"""
for idx, nucleon in enumerate(nucleons):
# print(f"PROC: {nucleon}")
worker = get_current_worker()
if worker and worker.is_cancelled: # 函数在worker中执行且已被取消
if worker and worker.is_cancelled: # Function running in worker and has been cancelled
return False
text = nucleon.metadata["formation"]["tts_text"]
text = nucleon["tts_text"]
# self.current_item = text[:30] + "..." if len(text) > 50 else text
# print(text)
self.processed += 1
@@ -135,12 +244,12 @@ class PrecachingScreen(Screen):
# print(self.total)
progress = int((self.processed / self.total) * 100) if self.total > 0 else 0
# print(progress)
self.update_status(f"正处理 ({idx + 1}/{len(nucleons)})", text, progress)
self.update_status(_("Processing ({i}/{total})").format(i=idx + 1, total=len(nucleons)), text, progress)
ret = self.precache_by_nucleon(nucleon)
if not ret:
self.update_status(
"出错",
f"处理失败, 跳过: {self.current_item}",
_("Error"),
_("Failed, skipping: {item}").format(item=self.current_item),
)
import time
@@ -157,41 +266,35 @@ class PrecachingScreen(Screen):
# print(f"返回 {ret}")
return ret
def precache_by_filepath(self, path: pathlib.Path):
"""预缓存单个文件的所有内容"""
lst = list()
for i in pt.load_nucleon(path):
lst.append(i[0])
return self.precache_by_list(lst)
def precache_all_files(self):
"""预缓存所有文件"""
from heurams.context import rootdir, workdir, config_var
from heurams.context import config_var
from heurams.kernel.repolib import Repo
nucleon_path = pathlib.Path(config_var.get()["paths"]["nucleon_dir"])
nucleon_files = [
f for f in nucleon_path.iterdir() if f.suffix == ".toml"
] # TODO: 解耦合
repo_path = pathlib.Path(config_var.get()["global"]["paths"]["data"]) / "repo"
repo_dirs = Repo.probe_valid_repos_in_dir(repo_path)
repos = map(Repo.from_repodir, repo_dirs)
# 计算总项目数
self.total = 0
nu = list()
for file in nucleon_files:
nucleon_list = list()
for repo in repos:
try:
for i in pt.load_nucleon(file):
nu.append(i[0])
for i in repo.ident_index:
nucleon_list.append(
pt.Nucleon.from_data(
repo.nucleonic_data_lict.get_itemic_unit(i)
)
)
except:
continue
self.total = len(nu)
for i in nu:
i: pt.Nucleon
i.do_eval()
return self.precache_by_list(nu)
self.total = len(nucleon_list)
return self.precache_by_list(nucleon_list)
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.id == "start_precache" and not self.is_precaching:
# 开始预缓存
# Start pre-cache
if self.nucleons:
self.precache_worker = self.run_worker(
self.precache_by_nucleons,
@@ -208,30 +311,32 @@ class PrecachingScreen(Screen):
)
elif event.button.id == "cancel_precache" and self.is_precaching:
# 取消预缓存
# Cancel pre-cache
if self.precache_worker:
self.precache_worker.cancel()
self.is_precaching = False
self.processed = 0
self.progress = 0
self.update_status("已取消", "预缓存操作被用户取消", 0)
self.update_status(_("Cancelled"), _("Pre-cache cancelled by user"), 0)
elif event.button.id == "clear_cache":
# 清空缓存
# Clear cache
try:
import shutil
from heurams.context import rootdir, workdir, config_var
shutil.rmtree(
f"{config_var.get()["paths"]["cache_dir"]}", ignore_errors=True
)
self.update_status("已清空", "音频缓存已清空", 0)
shutil.rmtree(cache_dir, ignore_errors=True)
self.update_status(_("Cleared"), _("Audio cache cleared"), 0)
self._update_cache_display() # Update cache stats display
except Exception as e:
self.update_status("错误", f"清空缓存失败: {e}")
self.update_status(_("Error"), _("Failed to clear cache: {error}").format(error=e))
self.cancel_flag = 1
self.processed = 0
self.progress = 0
elif event.button.id == "refresh_cache_stats":
# Refresh cache stats
self._update_cache_display()
self.app.notify(_("Cache info refreshed"), severity="information")
elif event.button.id == "go_back":
self.action_go_back()
@@ -239,8 +344,3 @@ class PrecachingScreen(Screen):
if self.is_precaching and self.precache_worker:
self.precache_worker.cancel()
self.app.pop_screen()
def action_quit_app(self):
if self.is_precaching and self.precache_worker:
self.precache_worker.cancel()
self.app.exit()
+151 -95
View File
@@ -1,21 +1,27 @@
#!/usr/bin/env python3
"""Memorization preparation screen"""
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Static,
Button,
Markdown,
)
from textual.containers import ScrollableContainer
from textual.containers import ScrollableContainer, Horizontal
from textual.screen import Screen
from heurams.context import config_var
from textual.widgets import (
Button,
Footer,
Header,
Label,
Markdown,
Static,
Sparkline,
)
from textual.lazy import Reveal
from textual import events, on
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.context import config_var
from heurams.i18n import _
from heurams.kernel.repolib import *
from heurams.kernel.algorithms import algorithms
from heurams.services.logger import get_logger
logger = get_logger(__name__)
@@ -23,69 +29,113 @@ logger = get_logger(__name__)
class PreparationScreen(Screen):
SUB_TITLE = "准备记忆集"
SUB_TITLE = _("Prepare Repository")
BINDINGS = [
("q", "go_back", "返回"),
("p", "precache", "预缓存音频"),
("q", "go_back", _("Back")),
("p", "precache", _("Cache")),
("d", "toggle_dark", ""),
("0,1,2,3", "app.push_screen('about')", ""),
]
scheduled_num = reactive(config_var.get()["scheduled_num"])
CSS_PATH = rootdir / "interface" / "css" / "screens" / "preparation.tcss"
def __init__(self, nucleon_file: pathlib.Path, electron_file: pathlib.Path) -> None:
def __init__(self, repo: Repo) -> None:
super().__init__(name=None, id=None, classes=None)
self.nucleon_file = nucleon_file
self.electron_file = electron_file
self.nucleons_with_orbital = pt.load_nucleon(self.nucleon_file)
self.electrons = pt.load_electron(self.electron_file)
self.repo = repo
self.load_data()
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer(id="vice_container"):
yield Label(f"准备就绪: [b]{self.nucleon_file.stem}[/b]\n")
from heurams.services.attic import Attic
a = Attic("ana", {"openpre": 0})
a.data["openpre"] += 1
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="main_container"):
yield Markdown(
_("**Ready**: `{title}`\n").format(title=self.repo.manifest['title']), id="title"
)
yield Label(_("Repo path: {path}").format(path=self.repo.source))
yield Label(
f"内容源文件: {config_var.get()['paths']['nucleon_dir']}/[b]{self.nucleon_file.name}[/b]"
_("Progress: {touched}/{total} [{pct}%]").format(
touched=self.repo.progress['touched'],
total=len(self.repo),
pct=round(self.repo.progress['touched'] / self.repo.progress['total'] * 100, 1)
)
)
yield Label(
f"元数据文件: {config_var.get()['paths']['electron_dir']}/[b]{self.electron_file.name}[/b]"
_("Scheduling algorithm: {algo} {desc}").format(
algo=self.repo.config["algorithm"],
desc=algorithms[self.repo.config["algorithm"]].desc
)
)
yield Label(f"\n单元数量: {len(self.nucleons_with_orbital)}\n")
yield Label(f"单次记忆数量: {self.scheduled_num}", id="schnum_label")
yield Button(
"开始记忆",
id="start_memorizing_button",
variant="primary",
classes="start-button",
)
yield Button(
"预缓存音频",
id="precache_button",
variant="success",
classes="precache-button",
yield Label(
_("Study count: {total} = {review} [d][Review][/d] + {new} [d][New][/d]\n").format(
total=self.repo.preview['review'] + self.scheduled_num,
review=self.repo.preview['review'],
new=self.scheduled_num,
),
id="schnum_label",
)
yield Static(f"\n单元预览:\n")
yield Markdown(self._get_full_content().replace("/", ""), classes="full")
yield Horizontal(
Button(
_("Start Memorizing"),
id="start_memorizing_button",
variant="primary",
classes="btn",
),
Button(
_("Manage Cache"),
id="precache_button",
variant="success",
classes="btn",
),
id="operations",
)
yield Static()
yield Sparkline(self.spark_line_arr, summary_function=max)
# yield Static(str(self.spark_line_arr))
with Reveal(ScrollableContainer(id="previewer_container")):
for i in self.content.splitlines():
yield Static(i, classes="unit-statline")
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):
def load_data(self):
self.scheduled_num = self.repo.config["scheduled_num"]
content = ""
for nucleon, orbital in self.nucleons_with_orbital:
nucleon: pt.Nucleon
# print(nucleon.payload)
content += " - " + nucleon["content"] + " \n"
return content
spark_line_arr = []
for i in self.repo.ident_index:
n = pt.Nucleon.from_data(
nucleonic_data=self.repo.nucleonic_data_lict.get_itemic_unit(i)
)
e = pt.Electron.from_data(
electronic_data=self.repo.electronic_data_lict.get_itemic_unit(i),
algo_name=self.repo.config["algorithm"],
)
statstr = ""
if e.is_activated():
statstr = "[#00ff00]A[/]"
if e.is_due():
statstr = "[#ffff00]R[/]"
# statstr += ('[dim]' + str(e.rept(real_rept=True)).zfill(2)+'[/]')
else:
statstr = "[#ff0000]U[/]"
spark_line_arr.append(e.rept(real_rept=True))
content += f" {statstr} {n['content'].replace('/', '')} \n"
self.content = content
self.spark_line_arr = spark_line_arr
def action_go_back(self):
self.app.pop_screen()
@@ -94,9 +144,13 @@ class PreparationScreen(Screen):
from ..screens.precache import PrecachingScreen
lst = list()
for i in self.nucleons_with_orbital:
lst.append(i[0])
precache_screen = PrecachingScreen(lst)
for i in self.repo.ident_index:
lst.append(
pt.Nucleon.from_data(self.repo.nucleonic_data_lict.get_itemic_unit(i))
)
precache_screen = PrecachingScreen(
nucleons=lst, desc=self.repo.manifest["title"]
)
self.app.push_screen(precache_screen)
def action_quit_app(self):
@@ -104,41 +158,43 @@ 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:
atom = pt.Atom(nucleon.ident)
atom.link("nucleon", nucleon)
try:
atom.link("electron", self.electrons[nucleon.ident])
except KeyError:
atom.link("electron", pt.Electron(nucleon.ident))
atom.link("orbital", orbital)
atom.link("nucleon_fmt", "toml")
atom.link("electron_fmt", "json")
atom.link("orbital_fmt", "toml")
atom.link("nucleon_path", self.nucleon_file)
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
launch(repo=self.repo, app=self.app, scheduled_num=self.scheduled_num)
memscreen = MemScreen(atoms_to_provide)
self.app.push_screen(memscreen)
elif event.button.id == "precache_button":
self.action_precache()
def launch(repo, app, scheduled_num):
if scheduled_num == -1:
scheduled_num = config_var.get()["interface"]["global"]["scheduled_num"]
atoms = list()
for i in repo.ident_index:
n = pt.Nucleon.from_data(
nucleonic_data=repo.nucleonic_data_lict.get_itemic_unit(i)
)
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict.get_itemic_unit(i),
algo_name=repo.config["algorithm"],
)
a = pt.Atom(n, e, repo.orbitic_data)
atoms.append(a)
atoms_to_provide = list()
left_new = scheduled_num
for i in atoms:
i: pt.Atom
if i.registry["electron"].is_activated():
if i.registry["electron"].is_due():
atoms_to_provide.append(i)
else:
left_new -= 1
if left_new >= 0:
atoms_to_provide.append(i)
import heurams.kernel.reactor as rt
from .memoqueue import MemScreen
router = rt.Router(atoms_to_provide)
memscreen = MemScreen(router=router, repo=repo)
app.push_screen(memscreen)
+247
View File
@@ -0,0 +1,247 @@
"""Settings screen"""
from textual.app import ComposeResult
from textual.containers import ScrollableContainer, Horizontal
from textual.screen import Screen
from textual.widgets import (
Footer,
Header,
Label,
Collapsible,
Input,
Switch,
Select,
)
from textual import events, on
from heurams.context import *
from heurams.i18n import _
from heurams.kernel.particles import *
from heurams.kernel.repolib import *
from heurams.services.logger import get_logger
from heurams.services.textproc import domize, undomize
from heurams.services.epath import epath
logger = get_logger(__name__)
class SettingScreen(Screen):
"""Settings screen"""
SUB_TITLE = _("Settings")
BINDINGS = [
("q", "go_back", _("Back")),
("s", "go_back", _("Settings")),
]
CSS_PATH = rootdir / "interface" / "css" / "screens" / "setting.tcss"
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult:
"""Compose UI components"""
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer():
yield Label("[b]" + _("Settings") + "[/b]")
for i in config_var.get():
if i.startswith("_"):
continue
a = self._get_subcfg(f"{i}")
if a:
yield Collapsible(
*a,
title=i + f'\n[d]{config_var.get().get(f"_{i}_desc", "")}[/d]',
)
yield Label(
_("Changes are saved immediately when you leave this page, but restart is recommended to ensure the new configuration is applied."),
classes="foot",
)
yield Footer()
def _get_subcfg(self, parent_epath: str):
parent = epath(config_var.get(), parent_epath)
if isinstance(parent, ConfigDict):
if parent.is_dir:
lst = list()
for i in parent:
if i.startswith("_"):
continue
a = self._get_subcfg(f"{parent_epath}.{i}")
if a:
lst.append(
Collapsible(
*a, title=i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'
)
)
return lst
if isinstance(parent, dict) or (
isinstance(parent, ConfigDict) and not parent.is_dir
):
lst = list()
for i in parent:
if i.startswith("_"):
continue
if isinstance(parent[i], dict):
a = self._get_subcfg(f"{parent_epath}.{i}")
if a:
lst.append(
Collapsible(
*a, title=i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'
)
)
elif f"_{i}_candidate" in parent: # 选择框模式
if isinstance(parent[f"_{i}_candidate"], dict):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Select(
(
(f"{j}\n[d]{k}[/d]", j)
for j, k in parent[f"_{i}_candidate"].items()
),
prompt=f'{parent.get(f"{i}", "")}',
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
elif isinstance(parent[f"_{i}_candidate"], list):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Select(
((j, j) for j in parent[f"_{i}_candidate"]),
prompt=f'{parent.get(f"{i}", "")}',
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
else:
if isinstance(parent[i], float):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Input(
value=str(parent[i]),
placeholder=_("Requires a float"),
type="number",
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
elif isinstance(parent[i], str):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Input(
value=parent[i],
placeholder=_("Requires a string"),
type="text",
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
elif isinstance(parent[i], bool):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Switch(
value=parent[i],
id=domize(f"{parent_epath}.{i}"),
classes="setting-switch",
),
classes="setting-item",
)
)
elif isinstance(parent[i], int):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Input(
value=str(parent[i]),
placeholder=_("Requires an integer"),
type="integer",
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
elif isinstance(parent[i], list):
pass
else:
lst.append(Label(_("Unknown type")))
return lst
return [Label(_("No sub-items"))]
def on_mount(self) -> None:
"""挂载组件时初始化"""
def action_go_back(self) -> None:
"""返回上一屏幕"""
config_var.get().persist()
self.app.pop_screen()
def action_quit_app(self) -> None:
"""退出应用程序"""
self.app.exit()
def action_open_navigator(self) -> None:
"""打开导航器"""
self.app.push_screen(NavigatorScreen())
def on_input_changed(self, event: Input.Changed) -> None:
widget_id = event.input.id
if not widget_id:
return
eepath = undomize(widget_id)
value = event.value
epath(
config_var.get(),
eepath,
enable_modify=True,
new_value=type(epath(config_var.get(), eepath))(value),
)
def on_switch_changed(self, event: Switch.Changed) -> None:
widget_id = event.switch.id
if not widget_id:
return
eepath = undomize(widget_id)
value = event.value
epath(
config_var.get(),
eepath,
enable_modify=True,
new_value=type(epath(config_var.get(), eepath))(value),
)
def on_select_changed(self, event: Select.Changed) -> None:
widget_id = event.select.id
if not widget_id:
return
eepath = undomize(widget_id)
value = event.value
epath(
config_var.get(),
eepath,
enable_modify=True,
new_value=type(epath(config_var.get(), eepath))(value),
)
+302 -22
View File
@@ -1,46 +1,326 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Button,
Static,
ProgressBar,
)
from textual.containers import ScrollableContainer, Horizontal
from textual.containers import ScrollableContainer
from textual.screen import Screen
import pathlib
"""Sync tool screen"""
import heurams.kernel.particles as pt
import heurams.services.hasher as hasher
from heurams.context import *
import pathlib
import time
from textual.app import ComposeResult
from textual.containers import Horizontal, ScrollableContainer
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, ProgressBar, Static
from textual.worker import get_current_worker
from textual import events, on
from heurams.context import *
from heurams.i18n import _
class SyncScreen(Screen):
BINDINGS = [("q", "go_back", "返回")]
BINDINGS = [("q", "go_back", _("Back"))]
def __init__(self, nucleons: list = [], desc: str = ""):
super().__init__(name=None, id=None, classes=None)
self.sync_service = None
self.is_syncing = False
self.is_paused = False
self.log_messages = []
self.max_log_lines = 50
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="sync_container"):
pass
# Title and connection status
yield Static(_("Sync Tool"), classes="title")
yield Static("", id="status_label", classes="status")
# Config info
yield Static(_("Sync protocol: {proto}").format(proto=config_var.get()['services']['sync']))
yield Static(_("Server Configuration:"), classes="section_title")
with Horizontal(classes="config_info"):
yield Static(_("Remote server:"), classes="config_label")
yield Static("", id="server_url", classes="config_value")
with Horizontal(classes="config_info"):
yield Static(_("Remote path:"), classes="config_label")
yield Static("", id="remote_path", classes="config_value")
with Horizontal(classes="control_buttons"):
yield Button(_("Test Connection"), id="test_connection", variant="primary")
yield Button(_("Start Sync"), id="start_sync", variant="success")
yield Button(_("Pause"), id="pause_sync", variant="warning", disabled=True)
yield Button(_("Cancel"), id="cancel_sync", variant="error", disabled=True)
yield Static(_("Sync Progress"), classes="section_title")
yield ProgressBar(id="progress_bar", show_percentage=True, total=100)
yield Static("", id="progress_label", classes="progress_text")
yield Static(_("Sync Log"), classes="section_title")
yield Static("", id="log_output", classes="log_output")
yield Footer()
def on_mount(self):
"""挂载时初始化状态"""
"""Initialise state on mount"""
self.update_ui_from_config()
self.log_message(_("Sync tool started"))
def update_ui_from_config(self):
"""Update UI with config info"""
try:
sync_cfg: dict = config_var.get()["providers"]["sync"]["webdav"]
# Update server URL
url = sync_cfg.get("url", _("Not configured"))
url_widget = self.query_one("#server_url")
url_widget.update(url) # type: ignore
# Update remote path
remote_path = sync_cfg.get("remote_path", "/")
path_widget = self.query_one("#remote_path")
path_widget.update(remote_path) # type: ignore
# Update status label
status_widget = self.query_one("#status_label")
if self.sync_service and self.sync_service.client:
status_widget.update(_("✅ Sync service ready")) # type: ignore
status_widget.add_class("ready")
else:
status_widget.update(_("❌ Sync service not configured or not enabled")) # type: ignore
status_widget.add_class("error")
except Exception as e:
self.log_message(_("Failed to update UI: {error}").format(error=e), is_error=True)
def update_status(self, status, current_item="", progress=None):
"""更新状态显示"""
"""Update status display"""
try:
status_widget = self.query_one("#status_label")
status_widget.update(status) # type: ignore
if progress is not None:
progress_bar = self.query_one("#progress_bar")
progress_bar.progress = progress # type: ignore
progress_label = self.query_one("#progress_label")
progress_label.update(f"{progress}% - {current_item}" if current_item else f"{progress}%") # type: ignore
except Exception as e:
self.log_message(_("Failed to update status: {error}").format(error=e), is_error=True)
def log_message(self, message: str, is_error: bool = False):
"""Add log message and update display"""
timestamp = time.strftime("%H:%M:%S")
prefix = "[ERROR]" if is_error else "[INFO]"
log_line = f"{timestamp} {prefix} {message}"
self.log_messages.append(log_line)
# Keep log lines under max
if len(self.log_messages) > self.max_log_lines:
self.log_messages = self.log_messages[-self.max_log_lines :]
# Update log display
try:
log_widget = self.query_one("#log_output")
log_widget.update("\n".join(self.log_messages)) # type: ignore
except Exception:
pass # Ignore if widget not ready
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button press events"""
button_id = event.button.id
if button_id == "test_connection":
self.test_connection()
elif button_id == "start_sync":
self.start_sync()
elif button_id == "pause_sync":
self.pause_sync()
elif button_id == "cancel_sync":
self.cancel_sync()
event.stop()
def test_connection(self):
"""Test WebDAV server connection"""
if not self.sync_service:
self.log_message(_("Sync service not initialised, please check configuration"), is_error=True)
self.update_status(_("❌ Sync service not initialised"))
return
self.log_message(_("Testing WebDAV connection..."))
self.update_status(_("Testing connection..."))
try:
success = self.sync_service.test_connection()
if success:
self.log_message(_("Connection test successful"))
self.update_status(_("✅ Connection OK"))
else:
self.log_message(_("Connection test failed"), is_error=True)
self.update_status(_("❌ Connection failed"))
except Exception as e:
self.log_message(_("Connection test error: {error}").format(error=e), is_error=True)
self.update_status(_("❌ Connection error"))
def start_sync(self):
"""Start syncing"""
if not self.sync_service:
self.log_message(_("Sync service not initialised, cannot start sync"), is_error=True)
return
if self.is_syncing:
self.log_message(_("Sync already in progress"), is_error=True)
return
self.is_syncing = True
self.is_paused = False
self.update_button_states()
self.log_message(_("Starting data sync..."))
self.update_status(_("Syncing..."), progress=0)
# Start background sync task
self.run_worker(self.perform_sync, thread=True)
def perform_sync(self):
"""Execute sync task (runs in background thread)"""
worker = get_current_worker()
try:
# Get local directories to sync
from heurams.context import config_var
config = config_var.get()
paths = config.get("paths", {})
# Sync nucleon directory
nucleon_dir = pathlib.Path(paths.get("nucleon_dir", "./data/nucleon"))
if nucleon_dir.exists():
self.log_message(_("Syncing nucleon directory: {dir}").format(dir=nucleon_dir))
self.update_status(_("Syncing nucleon directory..."), progress=10)
result = self.sync_service.sync_directory(nucleon_dir) # type: ignore
if result.get("success"):
self.log_message(
_("nucleon sync complete: uploaded {up}, downloaded {down}").format(
up=result.get('uploaded', 0),
down=result.get('downloaded', 0),
)
)
else:
self.log_message(
_("nucleon sync failed: {err}").format(err=result.get('error', _('Unknown error'))),
is_error=True,
)
# Sync electron directory
electron_dir = pathlib.Path(paths.get("electron_dir", "./data/electron"))
if electron_dir.exists():
self.log_message(_("Syncing electron directory: {dir}").format(dir=electron_dir))
self.update_status(_("Syncing electron directory..."), progress=60)
result = self.sync_service.sync_directory(electron_dir) # type: ignore
if result.get("success"):
self.log_message(
_("electron sync complete: uploaded {up}, downloaded {down}").format(
up=result.get('uploaded', 0),
down=result.get('downloaded', 0),
)
)
else:
self.log_message(
_("electron sync failed: {err}").format(err=result.get('error', _('Unknown error'))),
is_error=True,
)
# Sync orbital directory (if exists)
orbital_dir = pathlib.Path(paths.get("orbital_dir", "./data/orbital"))
if orbital_dir.exists():
self.log_message(_("Syncing orbital directory: {dir}").format(dir=orbital_dir))
self.update_status(_("Syncing orbital directory..."), progress=80)
result = self.sync_service.sync_directory(orbital_dir) # type: ignore
if result.get("success"):
self.log_message(
_("orbital sync complete: uploaded {up}, downloaded {down}").format(
up=result.get('uploaded', 0),
down=result.get('downloaded', 0),
)
)
else:
self.log_message(
_("orbital sync failed: {err}").format(err=result.get('error', _('Unknown error'))),
is_error=True,
)
# Sync complete
self.update_status(_("Sync complete"), progress=100)
self.log_message(_("All directories synced"))
except Exception as e:
self.log_message(_("Error during sync: {error}").format(error=e), is_error=True)
self.update_status(_("Sync failed"))
finally:
# Reset sync state
self.is_syncing = False
self.is_paused = False
self.update_button_states() # type: ignore
def pause_sync(self):
"""Pause sync"""
if not self.is_syncing:
return
self.is_paused = not self.is_paused
self.update_button_states()
if self.is_paused:
self.log_message(_("Sync paused"))
self.update_status(_("Sync paused"))
else:
self.log_message(_("Sync resumed"))
self.update_status(_("Syncing..."))
def cancel_sync(self):
"""Cancel sync"""
if not self.is_syncing:
return
self.is_syncing = False
self.is_paused = False
self.update_button_states()
self.log_message(_("Sync cancelled"))
self.update_status(_("Sync cancelled"))
def update_button_states(self):
"""Update button states"""
try:
start_button = self.query_one("#start_sync")
pause_button = self.query_one("#pause_sync")
cancel_button = self.query_one("#cancel_sync")
if self.is_syncing:
start_button.disabled = True
pause_button.disabled = False
cancel_button.disabled = False
pause_button.label = _("Resume") if self.is_paused else _("Pause") # type: ignore
else:
start_button.disabled = False
pause_button.disabled = True
cancel_button.disabled = True
except Exception as e:
self.log_message(_("Failed to update button state: {error}").format(error=e), is_error=True)
def action_go_back(self):
self.app.pop_screen()
+14 -26
View File
@@ -1,32 +1,10 @@
"""Kernel 操作辅助函数库"""
import random
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
import heurams.interface.widgets as pzw
from typing import TypedDict
staging = {} # 细粒度缓存区, 是 ident -> quality 的封装
def report_to_staging(atom: pt.Atom, quality):
staging[atom.ident] = min(quality, staging[atom.ident])
def clear():
staging = dict()
def deploy_to_electron():
for atom_ident, quality in staging.items():
if pt.atom_registry[atom_ident].registry["electron"].is_activated:
pt.atom_registry[atom_ident].registry["electron"].revisor(quality=quality)
else:
pt.atom_registry[atom_ident].registry["electron"].revisor(
quality=quality, is_new_activation=True
)
clear()
import heurams.kernel.puzzles as pz
import platform
import os
from heurams.context import config_var
puzzle2widget = {
pz.RecognitionPuzzle: pzw.Recognition,
@@ -34,3 +12,13 @@ puzzle2widget = {
pz.MCQPuzzle: pzw.MCQPuzzle,
pz.BasePuzzle: pzw.BasePuzzleWidget,
}
def set_term_title(title):
if not config_var.get()["interface"]["global"]["change_window_title"]:
return
system = platform.system()
if system == "Windows":
os.system(f"title {title}")
else: # Linux, Mac, etc.
os.write(2, f"\033]2;{title}\007".encode("utf-8"))
@@ -1,6 +1,7 @@
from typing import Iterable
from textual.app import ComposeResult
from textual.widget import Widget
import heurams.kernel.particles as pt
@@ -13,7 +14,7 @@ class BasePuzzleWidget(Widget):
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True
markup: bool = True,
) -> None:
super().__init__(
*children,
@@ -21,7 +22,7 @@ class BasePuzzleWidget(Widget):
id=id,
classes=classes,
disabled=disabled,
markup=markup
markup=markup,
)
self.atom = atom
+27 -28
View File
@@ -1,13 +1,12 @@
from textual.widgets import (
Label,
Static,
Button,
)
from textual.containers import ScrollableContainer, Horizontal
from textual.widget import Widget
import heurams.kernel.particles as pt
from .base_puzzle_widget import BasePuzzleWidget
from textual.containers import Horizontal, ScrollableContainer
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Button, Label, Static
import heurams.kernel.particles as pt
from heurams.i18n import _
from .base_puzzle_widget import BasePuzzleWidget
class BasicEvaluation(BasePuzzleWidget):
@@ -34,49 +33,49 @@ class BasicEvaluation(BasePuzzleWidget):
class RatingChanged(Message):
def __init__(self, rating: int) -> None:
self.rating = rating # 评分值 (0-5)
self.rating = rating # Rating value (0-5)
super().__init__()
# 反馈映射表
# Feedback mapping
feedback_mapping = {
"feedback_5": {"rating": 5, "text": "完美回想"},
"feedback_4": {"rating": 4, "text": "犹豫后正确"},
"feedback_3": {"rating": 3, "text": "困难地正确"},
"feedback_2": {"rating": 2, "text": "错误但熟悉"},
"feedback_1": {"rating": 1, "text": "错误且不熟"},
"feedback_0": {"rating": 0, "text": "完全空白"},
"feedback_5": {"rating": 5, "text": _("Perfect recall")},
"feedback_4": {"rating": 4, "text": _("Correct after hesitation")},
"feedback_3": {"rating": 3, "text": _("Correct with difficulty")},
"feedback_2": {"rating": 2, "text": _("Wrong but familiar")},
"feedback_1": {"rating": 1, "text": _("Wrong and unfamiliar")},
"feedback_0": {"rating": 0, "text": _("Complete blank")},
}
def compose(self):
# 显示主要内容
# Show main content
yield Label(self.atom.registry["nucleon"]["content"], id="main")
# 显示评估说明(可选)
yield Static("请评估你对这个内容的记忆程度: ", classes="instruction")
# Show instruction (optional)
yield Static(_("Evaluate how well you remember this content: "), classes="instruction")
# 按钮容器
# Button container
with ScrollableContainer(id="button_container"):
btn = {}
btn["5"] = Button(
"完美回想", variant="success", id="feedback_5", classes="choice"
_("Perfect recall"), variant="success", id="feedback_5", classes="choice"
)
btn["4"] = Button(
"犹豫后正确", variant="success", id="feedback_4", classes="choice"
_("Correct after hesitation"), variant="success", id="feedback_4", classes="choice"
)
btn["3"] = Button(
"困难地正确", variant="warning", id="feedback_3", classes="choice"
_("Correct with difficulty"), variant="warning", id="feedback_3", classes="choice"
)
btn["2"] = Button(
"错误但熟悉", variant="warning", id="feedback_2", classes="choice"
_("Wrong but familiar"), variant="warning", id="feedback_2", classes="choice"
)
btn["1"] = Button(
"错误且不熟", variant="error", id="feedback_1", classes="choice"
_("Wrong and unfamiliar"), variant="error", id="feedback_1", classes="choice"
)
btn["0"] = Button(
"完全空白", variant="error", id="feedback_0", classes="choice"
_("Complete blank"), variant="error", id="feedback_0", classes="choice"
)
# 布局按钮
# Layout buttons
yield Horizontal(btn["5"], btn["4"])
yield Horizontal(btn["3"], btn["2"])
yield Horizontal(btn["1"], btn["0"])
+54 -20
View File
@@ -1,18 +1,20 @@
from textual.widgets import (
Label,
Button,
)
from textual.widget import Widget
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from .base_puzzle_widget import BasePuzzleWidget
import copy
import random
from textual.containers import Container
from textual.message import Message
from heurams.services.logger import get_logger
from typing import TypedDict
from textual.containers import ScrollableContainer, Horizontal
from textual.widget import Widget
from textual.widgets import Button, Label, Markdown
from textual.events import Key
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from heurams.services.hasher import hash
from heurams.i18n import _
from heurams.services.logger import get_logger
from .base_puzzle_widget import BasePuzzleWidget
logger = get_logger(__name__)
@@ -50,10 +52,11 @@ class ClozePuzzle(BasePuzzleWidget):
self.hashtable = {}
self.alia = alia
self._load()
self.btn_shortcuts = {}
self.hashmap = dict()
def _load(self):
setting = self.atom.registry["orbital"]["puzzles"][self.alia]
setting = self.atom.registry["nucleon"]["puzzles"][self.alia]
self.puzzle = pz.ClozePuzzle(
text=setting["text"],
delimiter=setting["delimiter"],
@@ -65,20 +68,44 @@ class ClozePuzzle(BasePuzzleWidget):
def compose(self):
yield Label(self.puzzle.wording, id="sentence")
yield Label(f"当前输入: {self.inputlist}", id="inputpreview")
yield Markdown(f"> {self.listprint(self.inputlist)}", id="inputpreview")
# 渲染当前问题的选项
with Container(id="btn-container"):
with ScrollableContainer(id="btn-container") as s:
c = 0
btns = []
for i in self.ans:
self.hashmap[str(hash(i))] = i
btnid = f"sel000-{hash(i)}"
logger.debug(f"建立按钮 {btnid}")
yield Button(i, id=f"{btnid}")
h = str(hash(i))
if hash(i) in self.hashmap.keys():
continue
c += 1
self.hashmap[h] = i
btnid = f"sel000-{h}"
self.btn_shortcuts[f"{c}"] = btnid
btns.append(Button(f"{i}", id=f"{btnid}", classes="cloze-option-btn"))
for i in range((len(btns) + 1) // 2):
if 2 * i + 1 + 1 <= len(btns):
yield Horizontal(btns[i], btns[len(btns) - 1 - i], classes="hori")
else:
yield btns[i]
s.focus()
yield Button("退格", id="delete")
yield Button(_("Backspace"), id="delete")
self.btn_shortcuts[f"0"] = "delete"
self.btn_shortcuts[f"backspace"] = "delete"
self.btn_shortcuts[f"delete"] = "delete"
def listprint(self, lst):
s = ""
if lst:
lastone = lst[-1]
for i in lst[:-1]:
s += i + " "
s += f" `{lastone}`"
return s
def update_display(self):
preview = self.query_one("#inputpreview")
preview.update(f"当前输入: {self.inputlist}") # type: ignore
preview.update(f"> {self.listprint(self.inputlist)}") # type: ignore
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id
@@ -107,3 +134,10 @@ class ClozePuzzle(BasePuzzleWidget):
pass
else:
self.atom.minimize(rating)
def on_key(self, event: Key) -> None:
# self.notify(event.key)
if event.key in self.btn_shortcuts:
btn_id = self.btn_shortcuts.get(event.key)
btn_id = "#" + btn_id
self.query_one(btn_id, Button).press()
+10 -8
View File
@@ -1,8 +1,7 @@
from textual.widgets import (
Label,
Button,
)
from textual.widget import Widget
from textual.widgets import Button, Label
from heurams.i18n import _
class Finished(Widget):
@@ -10,25 +9,28 @@ class Finished(Widget):
self,
*children: Widget,
alia="",
is_saved=0,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True
markup: bool = True,
) -> None:
self.alia = alia
self.is_saved = is_saved
super().__init__(
*children,
name=name,
id=id,
classes=classes,
disabled=disabled,
markup=markup
markup=markup,
)
def compose(self):
yield Label("本次记忆进程结束", id="finished_msg")
yield Button("返回上一级", id="back-to-menu")
yield Label(_("This memorization session is finished"), id="finished_msg")
yield Label(_("Algorithm data {}").format(_("saved") if self.is_saved else _("not saved")))
yield Button(_("Back to Menu"), flat=True, id="back-to-menu")
def on_button_pressed(self, event):
button_id = event.button.id
+50 -32
View File
@@ -1,16 +1,17 @@
# 单项选择题
from textual.widgets import (
Label,
Button,
)
from textual.containers import ScrollableContainer, Container
# Multiple-choice puzzle
from typing import TypedDict
from textual.containers import ScrollableContainer
from textual.widget import Widget
from textual.widgets import Button, Label
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from .base_puzzle_widget import BasePuzzleWidget
from typing import TypedDict
from heurams.services.hasher import hash
from heurams.i18n import _
from heurams.services.logger import get_logger
from textual.events import Key
from .base_puzzle_widget import BasePuzzleWidget
logger = get_logger(__name__)
@@ -51,41 +52,48 @@ class MCQPuzzle(BasePuzzleWidget):
self.hashmap = dict()
self.cursor = 0
self.atom = atom
self.btn_shortcuts = {}
self._load()
def _load(self):
cfg = self.atom.registry["orbital"]["puzzles"][self.alia]
cfg = self.atom.registry["nucleon"]["puzzles"][self.alia]
if cfg["mapping"] == {}:
self.screen.rating = 5 # type: ignore
self.puzzle = pz.MCQPuzzle(
cfg["mapping"], cfg["jammer"], int(cfg["max_riddles_num"]), cfg["prefix"]
)
self.puzzle.refresh()
def compose(self):
self.atom.registry["nucleon"].do_eval()
setting: Setting = self.atom.registry["nucleon"].metadata["orbital"]["puzzles"][
self.alia
]
logger.debug(f"Puzzle Setting: {setting}")
setting: Setting = self.atom.registry["nucleon"]["puzzles"][self.alia]
current_options = self.puzzle.options[len(self.inputlist)]
yield Label(setting["primary"], id="sentence")
yield Label(self.puzzle.wording[len(self.inputlist)], id="puzzle")
yield Label(f"当前输入: {self.inputlist}", id="inputpreview")
yield Label(_("Current input: {input}").format(input=self.inputlist), id="inputpreview")
# 渲染当前问题的选项
with Container(id="btn-container"):
c = 0
with ScrollableContainer(id="btn-container") as s:
for i in current_options:
self.hashmap[str(hash(i))] = i
btnid = f"sel{str(self.cursor).zfill(3)}-{hash(i)}"
logger.debug(f"建立按钮 {btnid}")
yield Button(i, id=f"{btnid}")
if i in [" ", ""]:
continue
c += 1
h = str(hash(i))
self.hashmap[h] = i
btnid = f"sel{str(self.cursor).zfill(3)}-{h}"
self.btn_shortcuts[f"{c}"] = f"{btnid}"
yield Button(f"[{c}] " + i, id=f"{btnid}")
s.focus()
yield Button(_("Backspace"), id="delete")
yield Button("退格", id="delete")
self.btn_shortcuts["0"] = f"delete"
self.btn_shortcuts["delete"] = f"delete"
self.btn_shortcuts["backspace"] = f"delete"
def update_display(self, error=0):
# 更新预览标签
# Update preview label
preview = self.query_one("#inputpreview")
preview.update(f"当前输入: {self.inputlist}") # type: ignore
logger.debug("已经更新预览标签")
preview.update(_("Current input: {input}").format(input=self.inputlist)) # type: ignore
# 更新问题标签
puzzle_label = self.query_one("#puzzle")
current_question_index = len(self.inputlist)
@@ -108,7 +116,7 @@ class MCQPuzzle(BasePuzzleWidget):
# 选项选择处理
answer_text = self.hashmap[button_id[7:]] # type: ignore
self.inputlist.append(answer_text)
logger.debug(f"{self.inputlist}")
logger.debug(f"Input list: {self.inputlist}")
# 检查是否完成所有题目
if len(self.inputlist) >= len(self.puzzle.answer):
is_correct = self.inputlist == self.puzzle.answer
@@ -116,7 +124,7 @@ class MCQPuzzle(BasePuzzleWidget):
self.screen.rating = rating # type: ignore
self.handler(rating)
# 重置输入如果回答错误
# 重置输入(如果回答错误)
if not is_correct:
self.inputlist = []
self.refresh_buttons()
@@ -127,9 +135,8 @@ class MCQPuzzle(BasePuzzleWidget):
self.update_display()
def refresh_buttons(self):
"""刷新按钮显示用于题目切换"""
"""刷新按钮显示(用于题目切换)"""
# 移除所有选项按钮
logger.debug("刷新按钮")
self.cursor += 1
container = self.query_one("#btn-container")
buttons_to_remove = [
@@ -137,20 +144,24 @@ class MCQPuzzle(BasePuzzleWidget):
for child in container.children
if hasattr(child, "id") and child.id and child.id.startswith("sel")
]
container.focus()
for button in buttons_to_remove:
logger.info(button)
container.remove_children("#" + button.id) # type: ignore
# 添加当前题目的选项按钮
c = 0
current_question_index = len(self.inputlist)
if current_question_index < len(self.puzzle.options):
current_options = self.puzzle.options[current_question_index]
for option in current_options:
if option in ["", " "]:
continue
c += 1
button_id = f"sel{str(self.cursor).zfill(3)}-{hash(option)}"
if button_id not in self.hashmap:
self.hashmap[button_id] = option
new_button = Button(option, id=button_id)
self.hashmap[button_id[7:]] = option
new_button = Button(f"[{c}] " + option, id=button_id)
self.btn_shortcuts[f"{c}"] = button_id
container.mount(new_button)
def handler(self, rating):
@@ -158,3 +169,10 @@ class MCQPuzzle(BasePuzzleWidget):
pass
else:
self.atom.minimize(rating)
def on_key(self, event: Key) -> None:
self.notify(event.key)
if event.key in self.btn_shortcuts:
btn_id = self.btn_shortcuts.get(event.key)
btn_id = "#" + btn_id
self.query_one(btn_id, Button).press()
+7 -8
View File
@@ -1,8 +1,7 @@
from textual.widgets import (
Label,
Button,
)
from textual.widget import Widget
from textual.widgets import Button, Label
from heurams.i18n import _
class Placeholder(Widget):
@@ -14,7 +13,7 @@ class Placeholder(Widget):
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True
markup: bool = True,
) -> None:
super().__init__(
*children,
@@ -22,12 +21,12 @@ class Placeholder(Widget):
id=id,
classes=classes,
disabled=disabled,
markup=markup
markup=markup,
)
def compose(self):
yield Label("示例标签", id="testlabel")
yield Button("示例按钮", id="testbtn", classes="choice")
yield Label(_("Sample Label"), id="testlabel")
yield Button(_("Sample Button"), id="testbtn", classes="choice")
def on_button_pressed(self, event):
pass
+22 -31
View File
@@ -1,20 +1,16 @@
from textual.reactive import reactive
from textual.widgets import (
Markdown,
Label,
Static,
Button,
)
import re
from typing import Dict, List, TypedDict
from textual.containers import Center
from textual.widget import Widget
from typing import Dict
from textual.widgets import Button, Label, Markdown, Static
import heurams.kernel.particles as pt
import re
from .base_puzzle_widget import BasePuzzleWidget
from typing import TypedDict, List
from textual.message import Message
from heurams.i18n import _
from heurams.services.logger import get_logger
from .base_puzzle_widget import BasePuzzleWidget
logger = get_logger(__name__)
@@ -52,8 +48,13 @@ class Recognition(BasePuzzleWidget):
self.alia = alia
def compose(self):
cfg: RecognitionConfig = self.atom.registry["orbital"]["puzzles"][self.alia]
delim = self.atom.registry["nucleon"].metadata["formation"]["delimiter"]
from heurams.context import config_var
autovoice = config_var.get()["interface"]["widgets"]["recognition"]["autovoice"]
if autovoice:
self.screen.action_play_voice() # type: ignore
cfg: RecognitionConfig = self.atom.registry["nucleon"]["puzzles"][self.alia]
delim = self.atom.registry["nucleon"]["delimiter"]
replace_dict = {
", ": ",",
". ": ".",
@@ -66,12 +67,11 @@ class Recognition(BasePuzzleWidget):
f":{delim}": ":",
}
nucleon = self.atom.registry["nucleon"]
metadata = self.atom.registry["nucleon"].metadata
primary = cfg["primary"]
with Center():
yield Static(f"[dim]{cfg['top_dim']}[/]")
for i in cfg["top_dim"]:
yield Static(f"[dim]{i}[/]")
yield Label("")
for old, new in replace_dict.items():
@@ -87,30 +87,21 @@ class Recognition(BasePuzzleWidget):
for item in cfg["secondary"]:
if isinstance(item, list):
for j in item:
yield Markdown(f"### {metadata['annotation'][item]}: {j}")
yield Markdown(_("### Note: {note}").format(note=j)) # TODO ANNOTATION
continue
if isinstance(item, Dict):
total = ""
for j, k in item.items(): # type: ignore
total += f"> **{j}**: {k} \n"
total += f"> {j}: {k} \n"
yield Markdown(total)
if isinstance(item, str):
yield Markdown(item)
with Center():
yield Button("我已知晓", id="ok")
with Center() as c:
with Button(_("I know this"), id="ok") as b:
b.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok":
self.screen.rating = 5 # type: ignore
self.handler(5)
def handler(self, rating):
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
+2 -1
View File
@@ -1,2 +1,3 @@
# Kernel - HeurAMS 核心
记忆规划相关算法与数据结构, 可脱离业务层
包括记忆规划相关状态机, 算法, 仓库系统, 辅助库与数据结构, 可脱离业务层
+10 -11
View File
@@ -1,15 +1,14 @@
from .sm2 import SM2Algorithm
from heurams.services.logger import get_logger
import importlib
import pkgutil
from pathlib import Path
logger = get_logger(__name__)
from .base import BaseAlgorithm
__all__ = [
"SM2Algorithm",
]
__path__ = [str(Path(__file__).parent)]
algorithms = {
"SM-2": SM2Algorithm,
"supermemo2": SM2Algorithm,
}
for _finder, _name, _ispkg in pkgutil.iter_modules(__path__):
if _name == "base":
continue
importlib.import_module(f".{_name}", __package__)
logger.debug("算法模块初始化完成, 注册的算法: %s", list(algorithms.keys()))
algorithms = BaseAlgorithm.get_registry()
+23 -22
View File
@@ -1,15 +1,26 @@
import heurams.services.timer as timer
from typing import TypedDict
import heurams.services.timer as timer
from heurams.services.logger import get_logger
logger = get_logger(__name__)
_registry: dict[str, type["BaseAlgorithm"]] = {}
class BaseAlgorithm:
algo_name = "BaseAlgorithm"
desc = "算法基类"
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
_registry[cls.algo_name] = cls
@classmethod
def get_registry(cls) -> dict[str, type["BaseAlgorithm"]]:
return dict(_registry)
class AlgodataDict(TypedDict):
efactor: float
real_rept: int
rept: int
interval: int
@@ -33,37 +44,27 @@ class BaseAlgorithm:
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
) -> None:
"""迭代记忆数据"""
logger.debug(
"BaseAlgorithm.revisor 被调用, algodata keys: %s, feedback: %d, is_new_activation: %s",
list(algodata.keys()) if algodata else [],
feedback,
is_new_activation,
)
pass
@classmethod
def is_due(cls, algodata) -> int:
"""是否应该复习"""
logger.debug(
"BaseAlgorithm.is_due 被调用, algodata keys: %s",
list(algodata.keys()) if algodata else [],
)
return 1
@classmethod
def rate(cls, algodata) -> str:
def get_rating(cls, algodata) -> str:
"""获取评分信息"""
logger.debug(
"BaseAlgorithm.rate 被调用, algodata keys: %s",
list(algodata.keys()) if algodata else [],
)
return ""
@classmethod
def nextdate(cls, algodata) -> int:
"""获取下一次记忆时间戳"""
logger.debug(
"BaseAlgorithm.nextdate 被调用, algodata keys: %s",
list(algodata.keys()) if algodata else [],
)
return -1
@classmethod
def check_integrity(cls, algodata):
try:
cls.AlgodataDict(**algodata[cls.algo_name])
return 1
except:
return 0
+233 -2
View File
@@ -1,6 +1,237 @@
# FSRS 算法模块, 尚未就绪
"""
FSRS 算法模块 — 基于 py-fsrs 的现代间隔重复调度器
基于: https://github.com/open-spaced-repetition/py-fsrs
"""
import os
import pathlib
from datetime import datetime, timezone, timedelta
from typing import TypedDict
from fsrs import Scheduler, Card, Rating
from heurams.context import config_var
from heurams.services.logger import get_logger
from heurams.services.timer import get_daystamp, get_timestamp
from .base import BaseAlgorithm
logger = get_logger(__name__)
logger.info("FSRS算法模块尚未实现")
# 全局 Scheduler 状态文件路径
_SCHEDULER_STATE_FILE = (
pathlib.Path(config_var.get()["global"]["paths"]["misc"])
/ "fsrs_scheduler_state.json"
)
def _get_global_scheduler():
"""获取全局 FSRS Scheduler 实例, 从文件加载或创建新的"""
if os.path.exists(_SCHEDULER_STATE_FILE):
try:
with open(_SCHEDULER_STATE_FILE, "r", encoding="utf-8") as f:
return Scheduler.from_json(f.read())
except Exception:
logger.warning("No former FSRS Scheduler file founded, creating new instance")
return Scheduler()
def _save_global_scheduler(scheduler):
"""保存全局 FSRS Scheduler 实例到文件"""
try:
_SCHEDULER_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
data = scheduler.to_json()
with open(_SCHEDULER_STATE_FILE, "w", encoding="utf-8") as f:
f.write(data)
except Exception:
logger.error("Failed to persist FSRS Scheduler state")
def _feedback_to_rating(feedback: int) -> Rating:
"""将 SM-2 风格 feedback (0-5) 映射为 FSRS Rating (1-4)"""
if feedback <= 2:
return Rating.Again
elif feedback == 3:
return Rating.Hard
elif feedback == 4:
return Rating.Good
else:
return Rating.Easy
def _datetime_to_daystamp(dt: datetime) -> int:
"""将 datetime 转换为天数戳(从 1970-01-01"""
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
delta = dt - epoch
return delta.days
def _daystamp_to_datetime(daystamp: int) -> datetime:
"""将天数戳转换为 UTC datetime"""
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
return epoch + timedelta(days=daystamp)
class FSRSAlgorithm(BaseAlgorithm):
algo_name = "FSRS"
desc = "基于 py-fsrs 的现代间隔重复调度器"
class AlgodataDict(TypedDict):
# FSRS 特有字段
fsrs_state: int # State 枚举值: 1=Learning, 2=Review, 3=Relearning
fsrs_step: int # 当前学习步进索引, -1 表示 None (Review 状态)
fsrs_stability: float # 稳定性(秒), 0.0 表示尚未计算
fsrs_difficulty: float # 难度 [1.0, 10.0], 0.0 表示尚未计算
# 标准 BaseAlgorithm 兼容字段
real_rept: int
rept: int
interval: int
last_date: int
next_date: int
is_activated: int
last_modify: float
defaults = {
"fsrs_state": 1, # State.Learning
"fsrs_step": 0,
"fsrs_stability": 0.0,
"fsrs_difficulty": 0.0,
"real_rept": 0,
"rept": 0,
"interval": 0,
"last_date": 0,
"next_date": 0,
"is_activated": 0,
"last_modify": get_timestamp(),
}
@classmethod
def _algodata_to_card(cls, algodata):
"""从 algodata 恢复 Card 实例"""
data = algodata.get(cls.algo_name, cls.defaults.copy())
card = Card()
# State: int → IntEnum
card.state = data.get("fsrs_state", 1)
# Step: -1 表示 NoneReview 状态下的 card.step 为 None
step = data.get("fsrs_step", -1)
card.step = None if step == -1 else step
# Stability: 0.0 表示尚未计算(新卡片)
stability = data.get("fsrs_stability", 0.0)
card.stability = None if stability == 0.0 else stability
# Difficulty: 0.0 表示尚未计算
difficulty = data.get("fsrs_difficulty", 0.0)
card.difficulty = None if difficulty == 0.0 else difficulty
# due: 新卡片(next_date ≤ 0)设为当前时间
next_date = data.get("next_date", 0)
if next_date <= 0:
card.due = datetime.now(timezone.utc)
else:
card.due = _daystamp_to_datetime(next_date)
# last_review
last_date = data.get("last_date", 0)
card.last_review = _daystamp_to_datetime(last_date) if last_date > 0 else None
return card
@classmethod
def _card_to_algodata(cls, card, algodata):
"""将 Card 实例状态写回 algodata"""
if cls.algo_name not in algodata:
algodata[cls.algo_name] = cls.defaults.copy()
data = algodata[cls.algo_name]
data["fsrs_state"] = int(card.state)
data["fsrs_step"] = card.step if card.step is not None else -1
data["fsrs_stability"] = card.stability if card.stability is not None else 0.0
data["fsrs_difficulty"] = (
card.difficulty if card.difficulty is not None else 0.0
)
data["last_date"] = (
_datetime_to_daystamp(card.last_review)
if card.last_review
else data.get("last_date", 0)
)
data["next_date"] = _datetime_to_daystamp(card.due) if card.due else 0
data["interval"] = max(0, data["next_date"] - data["last_date"])
data["last_modify"] = get_timestamp()
return algodata
@classmethod
def revisor(
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
):
"""FSRS 算法迭代决策机制实现
将 feedback (0-5) 映射为 FSRS Rating 后交由 py-fsrs 调度器处理。
Args:
feedback (int): 0-5 的记忆保留率量化参数
is_new_activation: 是否为全新激活(重置为初始状态)
"""
if feedback == -1:
logger.debug("feedback = -1, update skipped")
return
scheduler = _get_global_scheduler()
rating = _feedback_to_rating(feedback)
if is_new_activation:
card = Card()
logger.debug("New activation, create new Card")
else:
card = cls._algodata_to_card(algodata)
card, review_log = scheduler.review_card(card, rating)
_save_global_scheduler(scheduler)
cls._card_to_algodata(card, algodata)
# real_rept: 总复习次数
algodata[cls.algo_name]["real_rept"] += 1
# rept: 成功回忆次数(feedback ≥ 3 视为成功)
if feedback >= 3:
algodata[cls.algo_name]["rept"] += 1
logger.debug(
"FSRS.revisor finished: stability=%s, difficulty=%s, state=%s, " "next_date=%d",
card.stability,
card.difficulty,
card.state,
algodata[cls.algo_name]["next_date"],
)
@classmethod
def is_due(cls, algodata):
data = algodata.get(cls.algo_name, cls.defaults.copy())
next_date = data.get("next_date", 0)
current = get_daystamp()
result = next_date <= current
logger.debug(
"FSRS.is_due: next_date=%d, current=%d, result=%s",
next_date,
current,
result,
)
return result
@classmethod
def get_rating(cls, algodata):
data = algodata.get(cls.algo_name, cls.defaults.copy())
difficulty = data.get("fsrs_difficulty", 0.0)
logger.debug("FSRS.get_rating: difficulty=%f", difficulty)
return str(difficulty)
@classmethod
def nextdate(cls, algodata) -> int:
data = algodata.get(cls.algo_name, cls.defaults.copy())
next_date = data.get("next_date", 0)
logger.debug("FSRS.nextdate: %d", next_date)
return next_date
+95
View File
@@ -0,0 +1,95 @@
from typing import TypedDict
import heurams.services.timer as timer
from heurams.services.logger import get_logger
from .base import BaseAlgorithm
logger = get_logger(__name__)
class NSP0Algorithm(BaseAlgorithm):
algo_name = "NSP-0"
desc = "快速筛选用非间隔重复调度器"
class AlgodataDict(TypedDict):
real_rept: int
rept: int
interval: int
important: int
last_date: int
next_date: int
is_activated: int
last_modify: float
defaults = {
"real_rept": 0,
"important": 0,
"rept": 0,
"interval": 0,
"last_date": 0,
"next_date": 0,
"is_activated": 0,
"last_modify": timer.get_timestamp(),
}
@classmethod
def revisor(
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
):
"""NSP-0 算法迭代决策机制实现
根据 quality(0 ~ 5) 进行参数迭代最佳间隔
quality 由主程序评估
Args:
quality (int): 记忆保留率量化参数
"""
logger.debug(
"NSP0.revisor 开始, feedback: %d, is_new_activation: %s",
feedback,
is_new_activation,
)
if feedback == -1:
logger.debug("feedback = -1, update skipped")
return
algodata[cls.algo_name]["interval"] = 1 if feedback <= 3 else float("inf")
if not algodata[cls.algo_name]["important"]:
algodata[cls.algo_name]["important"] = (
1 if feedback <= 3 else algodata[cls.algo_name]["important"]
)
algodata[cls.algo_name]["last_date"] = timer.get_daystamp()
algodata[cls.algo_name]["next_date"] = (
timer.get_daystamp() + algodata[cls.algo_name]["interval"]
)
algodata[cls.algo_name]["last_modify"] = timer.get_timestamp()
logger.debug(
"Update date: last_date=%d, next_date=%d, last_modify=%f",
algodata[cls.algo_name]["last_date"],
algodata[cls.algo_name]["next_date"],
algodata[cls.algo_name]["last_modify"],
)
@classmethod
def is_due(cls, algodata):
result = algodata[cls.algo_name]["next_date"] <= timer.get_daystamp()
logger.debug(
"NSP0.is_due: next_date=%d, current_daystamp=%d, result=%s",
algodata[cls.algo_name]["next_date"],
timer.get_daystamp(),
result,
)
return result
@classmethod
def get_rating(cls, algodata):
efactor = algodata[cls.algo_name]["efactor"]
logger.debug("NSP0.rate: efactor=%f", efactor)
return str(efactor)
@classmethod
def nextdate(cls, algodata) -> int:
next_date = algodata[cls.algo_name]["next_date"]
logger.debug("NSP0.nextdate: %d", next_date)
return next_date
+835
View File
@@ -0,0 +1,835 @@
"""
SM-15M — 基于 sm.js 的间隔重复算法
基于: https://github.com/slaypni/sm.js
原始 CoffeeScript (c) 2014 Kazuaki Tanida, MIT 许可证
"""
import datetime
import json
import math
import os
import pathlib
from typing import TypedDict
from heurams.context import config_var
from heurams.services.logger import get_logger
from heurams.services.timer import (
get_daystamp,
get_timestamp,
get_timestamp_ms,
daystamp_to_datetime,
datetime_to_daystamp,
)
from .base import BaseAlgorithm
logger = get_logger(__name__)
# ============================================================================
# Constants
# ============================================================================
RANGE_AF = 20
RANGE_REPETITION = 20
MIN_AF = 1.2
NOTCH_AF = 0.3
MAX_AF = MIN_AF + NOTCH_AF * (RANGE_AF - 1) # 6.9
MAX_GRADE = 5
THRESHOLD_RECALL = 3
# ============================================================================
# Math Helpers
# ============================================================================
def sum_values(values):
return sum(values)
def exponential_regression(points):
"""y = a * exp(b * x)"""
n = len(points)
X = [p[0] for p in points]
Y = [p[1] for p in points]
logY = [math.log(y) for y in Y]
sqX = [x * x for x in X]
sum_logY = sum(logY)
sum_sqX = sum(sqX)
sumX = sum(X)
sumX_logY = sum(X[i] * logY[i] for i in range(n))
sq_sumX = sumX * sumX
denom = n * sum_sqX - sq_sumX
a_coeff = (sum_logY * sum_sqX - sumX * sumX_logY) / denom if denom else 0
b_coeff = (n * sumX_logY - sumX * sum_logY) / denom if denom else 0
a = math.exp(a_coeff)
def y_func(x):
return a * math.exp(b_coeff * x)
def x_func(y):
if b_coeff == 0:
return 0
return (-a_coeff + math.log(y)) / b_coeff
return {"y": y_func, "x": x_func, "a": a, "b": b_coeff}
def linear_regression(points):
"""y = a + b * x"""
n = len(points)
X = [p[0] for p in points]
Y = [p[1] for p in points]
sumX = sum(X)
sumY = sum(Y)
sumXY = sum(X[i] * Y[i] for i in range(n))
sum_sqX = sum(x * x for x in X)
sq_sumX = sumX * sumX
denom = n * sum_sqX - sq_sumX
a = (sumY * sum_sqX - sumX * sumXY) / denom if denom else 0
b = (n * sumXY - sumX * sumY) / denom if denom else 0
def y_func(x):
return a + b * x
def x_func(y):
if b == 0:
return 0
return (y - a) / b
return {"y": y_func, "x": x_func, "a": a, "b": b}
def power_law_model(a, b):
"""y = a * x^b"""
def y_func(x):
return a * (x**b)
def x_func(y):
if a == 0 or b == 0:
return 0
return (y / a) ** (1.0 / b)
return {"y": y_func, "x": x_func, "a": a, "b": b}
def fixed_point_power_law_regression(points, fixed_point):
"""y = q * (x/p)^b, through fixed point (p, q)"""
n = len(points)
p, q = fixed_point
log_q = math.log(q)
X = [math.log(point[0] / p) for point in points]
Y = [math.log(point[1]) - log_q for point in points]
sumXY = sum(X[i] * Y[i] for i in range(n))
sum_sqX = sum(x * x for x in X)
b = sumXY / sum_sqX if sum_sqX else 0
return power_law_model(q / (p**b), b)
# ============================================================================
# FI-G Grade Graph
# ============================================================================
class FI_G:
"""Forgetting Index — Grade graph (exponential regression)."""
MAX_POINTS_COUNT = 5000
GRADE_OFFSET = 1
def __init__(self, sm, points=None):
self.sm = sm
self._graph = None
if points is not None:
self.points = points
else:
self.points = []
self._register_point(0, MAX_GRADE)
self._register_point(100, 0)
def _register_point(self, fi, grade):
self.points.append([fi, grade + self.GRADE_OFFSET])
if len(self.points) > self.MAX_POINTS_COUNT:
self.points = self.points[-self.MAX_POINTS_COUNT :]
self._graph = None
def update(self, grade, item, now):
def expected_fi():
return (item.uf(now) / item.of) * self.sm.requested_fi
self._register_point(expected_fi(), grade)
def fi(self, grade):
if not self.points:
return 50.0
if self._graph is None:
self._graph = exponential_regression(self.points)
return max(0.0, min(100.0, self._graph["x"](grade + self.GRADE_OFFSET)))
def data(self):
return {"points": self.points}
@classmethod
def load(cls, sm, data):
return cls(sm, data.get("points"))
# ============================================================================
# Forgetting Curve (single cell)
# ============================================================================
class ForgettingCurve:
MAX_POINTS_COUNT = 500
FORGOTTEN = 1
REMEMBERED = 100 + FORGOTTEN # 101
def __init__(self, points):
self.points = points
self._curve = None
def register_point(self, grade, uf):
is_remembered = grade >= THRESHOLD_RECALL
self.points.append([uf, self.REMEMBERED if is_remembered else self.FORGOTTEN])
if len(self.points) > self.MAX_POINTS_COUNT:
self.points = self.points[-self.MAX_POINTS_COUNT :]
self._curve = None
def retention(self, uf):
if not self.points:
return 50.0
if self._curve is None:
self._curve = exponential_regression(self.points)
clamped = max(self.FORGOTTEN, min(self._curve["y"](uf), self.REMEMBERED))
return clamped - self.FORGOTTEN
def uf(self, retention):
if not self.points:
return 1.0
if self._curve is None:
self._curve = exponential_regression(self.points)
return max(0.0, self._curve["x"](retention + self.FORGOTTEN))
# ============================================================================
# Forgetting Curves Matrix (repetition × af)
# ============================================================================
class ForgettingCurves:
FORGOTTEN = 1
REMEMBERED = 100 + FORGOTTEN
def __init__(self, sm, points=None):
self.sm = sm
self.curves = []
for r in range(RANGE_REPETITION):
row = []
for a in range(RANGE_AF):
if points is not None:
partial = points[r][a]
else:
if r > 0:
pts = []
for i in range(21):
v = MIN_AF + NOTCH_AF * i
y = math.exp(
-(r + 1) / 200 * (i - a * math.sqrt(2.0 / (r + 1)))
) * (self.REMEMBERED - self.sm.requested_fi)
pts.append([v, min(self.REMEMBERED, y)])
partial = [[0, self.REMEMBERED]] + pts
else:
pts = []
for i in range(21):
v = MIN_AF + NOTCH_AF * i
y = math.exp(-1.0 / (10 + 1 * (a + 1)) * (i - a**0.6)) * (
self.REMEMBERED - self.sm.requested_fi
)
pts.append([v, min(self.REMEMBERED, y)])
partial = [[0, self.REMEMBERED]] + pts
row.append(ForgettingCurve(partial))
self.curves.append(row)
def register_point(self, grade, item, now):
af_index = item.af_index() if item.repetition > 0 else item.lapse
self.curves[item.repetition][af_index].register_point(grade, item.uf(now))
def data(self):
return {
"points": [
[self.curves[r][a].points for a in range(RANGE_AF)]
for r in range(RANGE_REPETITION)
]
}
@classmethod
def load(cls, sm, data):
return cls(sm, data.get("points"))
# ============================================================================
# R-Factor Matrix
# ============================================================================
class RFM:
def __init__(self, sm):
self.sm = sm
def rf(self, repetition, af_index):
return self.sm.forgetting_curves.curves[repetition][af_index].uf(
100 - self.sm.requested_fi
)
# ============================================================================
# O-Factor Matrix
# ============================================================================
class OFM:
INITIAL_REP_VALUE = 1
def __init__(self, sm):
self.sm = sm
self._ofm = None
self._ofm0 = None
self.update()
def update(self):
def af_from_index(a):
return a * NOTCH_AF + MIN_AF
def rep_from_index(r):
return r + self.INITIAL_REP_VALUE
# D-factors: power law decay along repetition axis
dfs = []
for a in range(RANGE_AF):
pts = [
[rep_from_index(r), self.sm.rfm.rf(r, a)]
for r in range(1, RANGE_REPETITION)
]
fp = [rep_from_index(1), af_from_index(a)]
model = fixed_point_power_law_regression(pts, fp)
dfs.append(model["b"])
# Transform D-factors
dfs_t = [af_from_index(a) / (2.0 ** dfs[a]) for a in range(RANGE_AF)]
# Linear regression of D-factor by A-Factor index
decay_pts = [[a, dfs_t[a]] for a in range(RANGE_AF)]
decay = linear_regression(decay_pts)
# Build O-Factor model per A-Factor
ofm_list = []
for a in range(RANGE_AF):
af = af_from_index(a)
d_y = decay["y"](a)
b = math.log(af / d_y) / math.log(rep_from_index(1)) if d_y != 0 else 0
model = power_law_model(af / (rep_from_index(1) ** b), b)
def make_ofm_funcs(m):
return {
"y": lambda r, m=m: m["y"](rep_from_index(r)),
"x": lambda y, m=m: m["x"](y) - self.INITIAL_REP_VALUE,
}
ofm_list.append(make_ofm_funcs(model))
self._ofm = ofm_list
# O-Factor for repetition 0
ofm0_pts = [[a, self.sm.rfm.rf(0, a)] for a in range(RANGE_AF)]
ofm0 = exponential_regression(ofm0_pts)
self._ofm0 = lambda a: ofm0["y"](a)
def of(self, repetition, af_index):
if repetition == 0:
return self._ofm0(af_index)
return self._ofm[af_index]["y"](repetition)
def af(self, repetition, of_val):
for a in range(RANGE_AF):
if abs(self.of(repetition, a) - of_val) < 1e-10:
return a * NOTCH_AF + MIN_AF
# Find closest
best, best_a = float("inf"), 0
for a in range(RANGE_AF):
d = abs(self.of(repetition, a) - of_val)
if d < best:
best, best_a = d, a
return best_a * NOTCH_AF + MIN_AF
# ============================================================================
# Item (per-card state)
# ============================================================================
class Item:
MAX_AFS_COUNT = 30
def __init__(self, sm, value=None):
self.sm = sm
self.value = value
self.lapse = 0
self.repetition = -1
self.of = 1.0
self.optimum_interval = sm.interval_base # ms
self.due_date = datetime.datetime(1970, 1, 1)
self.previous_date = None
self._afs = []
self._af = None
def interval(self, now=None):
if now is None:
now = datetime.datetime.now()
if self.previous_date is None:
return self.sm.interval_base
return (now - self.previous_date).total_seconds() * 1000
def uf(self, now=None):
if now is None:
now = datetime.datetime.now()
adjusted = self.optimum_interval / self.of
return self.interval(now) / adjusted if adjusted else 0
def af(self, value=None):
if value is None:
return self._af
a = round((value - MIN_AF) / NOTCH_AF)
self._af = max(MIN_AF, min(MAX_AF, MIN_AF + a * NOTCH_AF))
return self._af
def af_index(self):
target = self.af() if self._af is not None else MIN_AF
best, best_i = float("inf"), 0
for i in range(RANGE_AF):
d = abs(target - (MIN_AF + i * NOTCH_AF))
if d < best:
best, best_i = d, i
return best_i
def _I(self, now=None):
if now is None:
now = datetime.datetime.now()
af_idx = self.lapse if self.repetition == 0 else self.af_index()
of_val = self.sm.ofm.of(self.repetition, af_idx)
self.of = max(
1.0, (of_val - 1) * (self.interval(now) / self.optimum_interval) + 1
)
self.optimum_interval = round(self.optimum_interval * self.of)
self.previous_date = now
self.due_date = now + datetime.timedelta(milliseconds=self.optimum_interval)
def _update_af(self, grade, now=None):
if now is None:
now = datetime.datetime.now()
estimated_fi = max(1.0, self.sm.fi_g.fi(grade))
corrected_uf = self.uf(now) * (self.sm.requested_fi / estimated_fi)
if self.repetition > 0:
estimated_af = self.sm.ofm.af(self.repetition, corrected_uf)
else:
estimated_af = max(MIN_AF, min(MAX_AF, corrected_uf))
self._afs.append(estimated_af)
if len(self._afs) > self.MAX_AFS_COUNT:
self._afs = self._afs[-self.MAX_AFS_COUNT :]
wsum = sum(af * (i + 1) for i, af in enumerate(self._afs))
wtotal = sum(range(1, len(self._afs) + 1))
self.af(wsum / wtotal if wtotal else estimated_af)
def answer(self, grade, now=None):
if now is None:
now = datetime.datetime.now()
if self.repetition >= 0:
self._update_af(grade, now)
if grade >= THRESHOLD_RECALL:
if self.repetition < RANGE_REPETITION - 1:
self.repetition += 1
self._I(now)
else:
if self.lapse < RANGE_AF - 1:
self.lapse += 1
self.optimum_interval = self.sm.interval_base
self.previous_date = None
self.due_date = now
self.repetition = -1
def data(self):
return {
"value": self.value,
"repetition": self.repetition,
"lapse": self.lapse,
"of": self.of,
"optimumInterval": self.optimum_interval,
"dueDate": self.due_date.isoformat(),
"previousDate": (
self.previous_date.isoformat() if self.previous_date else None
),
"_afs": self._afs,
}
@classmethod
def load(cls, sm, data):
item = cls(sm)
item.value = data.get("value")
item.repetition = data.get("repetition", -1)
item.lapse = data.get("lapse", 0)
item.of = data.get("of", 1.0)
item.optimum_interval = data.get("optimumInterval", sm.interval_base)
item._afs = data.get("_afs", [])
due_str = data.get("dueDate")
if due_str:
if isinstance(due_str, str):
item.due_date = datetime.datetime.fromisoformat(
due_str.replace("Z", "+00:00")
)
else:
item.due_date = datetime.datetime.fromtimestamp(due_str / 1000)
prev_str = data.get("previousDate")
if prev_str:
if isinstance(prev_str, str):
item.previous_date = datetime.datetime.fromisoformat(
prev_str.replace("Z", "+00:00")
)
else:
item.previous_date = datetime.datetime.fromtimestamp(prev_str / 1000)
if item._afs:
item.af(sum(item._afs) / len(item._afs))
return item
# ============================================================================
# SM (global scheduler)
# ============================================================================
class SM:
def __init__(self):
self.requested_fi = 10.0
self.interval_base = 3 * 60 * 60 * 1000 # 3 hours in ms
self.q = []
self.fi_g = FI_G(self)
self.forgetting_curves = ForgettingCurves(self)
self.rfm = RFM(self)
self.ofm = OFM(self)
def _find_index_to_insert(self, item, r=None):
if r is None:
r = list(range(len(self.q)))
if not r:
return 0
v = item.due_date
i = len(r) // 2
if len(r) == 1:
return r[i] if v < self.q[r[i]].due_date else r[i] + 1
if v < self.q[r[i]].due_date:
return self._find_index_to_insert(item, r[:i])
return self._find_index_to_insert(item, r[i:])
def add_item(self, value):
item = Item(self, value)
idx = self._find_index_to_insert(item)
self.q.insert(idx, item)
def next_item(self, is_advanceable=False):
if not self.q:
return None
now = datetime.datetime.now()
if is_advanceable or self.q[0].due_date < now:
return self.q[0]
return None
def answer(self, grade, item, now=None):
if now is None:
now = datetime.datetime.now()
self._update(grade, item, now)
self.discard(item)
idx = self._find_index_to_insert(item)
self.q.insert(idx, item)
def _update(self, grade, item, now=None):
if now is None:
now = datetime.datetime.now()
if item.repetition >= 0:
self.forgetting_curves.register_point(grade, item, now)
self.ofm.update()
self.fi_g.update(grade, item, now)
item.answer(grade, now)
def discard(self, item):
if item in self.q:
self.q.remove(item)
def data(self):
return {
"requestedFI": self.requested_fi,
"intervalBase": self.interval_base,
"q": [item.data() for item in self.q],
"fi_g": self.fi_g.data(),
"forgettingCurves": self.forgetting_curves.data(),
"version": 1,
}
@classmethod
def load(cls, data):
sm = cls()
sm.requested_fi = data.get("requestedFI", 10.0)
sm.interval_base = data.get("intervalBase", 3 * 60 * 60 * 1000)
sm.q = [Item.load(sm, d) for d in data.get("q", [])]
sm.fi_g = FI_G.load(sm, data.get("fi_g", {}))
sm.forgetting_curves = ForgettingCurves.load(
sm, data.get("forgettingCurves", {})
)
sm.rfm = RFM(sm)
sm.ofm = OFM(sm)
return sm
# ============================================================================
# Global state management
# ============================================================================
_GLOBAL_STATE_FILE = (
pathlib.Path(config_var.get()["global"]["paths"]["misc"])
/ "sm15m_global_state.json"
)
def _get_global_sm():
if os.path.exists(_GLOBAL_STATE_FILE):
try:
with open(_GLOBAL_STATE_FILE, "r", encoding="utf-8") as f:
return SM.load(json.load(f))
except Exception:
logger.warning("Failed to load SM-15M global state file, creating new instance")
sm = SM()
_save_global_sm(sm)
return sm
def _save_global_sm(sm):
try:
_GLOBAL_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(_GLOBAL_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(sm.data(), f, indent=2)
except Exception:
logger.error("Failed to save SM-15M global state")
# ============================================================================
# SM15MAlgorithm (HeurAMS interface)
# ============================================================================
class SM15MAlgorithm(BaseAlgorithm):
algo_name = "SM-15M"
desc = "基于 sm.js 的 SM-15 间隔重复算法"
class AlgodataDict(TypedDict):
# SM-15M 特有
lapse: int
repetition: int
of_val: float # O-Factor
optimum_interval_days: int # 最优间隔(天)
afs: list # A-Factor 历史
af: float # 当前 A-Factor
# 毫秒精度(子日排程)
last_date_ms: int
next_date_ms: int
# BaseAlgorithm 兼容(天精度, 向后兼容)
real_rept: int
rept: int
interval: int
last_date: int
next_date: int
is_activated: int
last_modify: float
defaults = {
"lapse": 0,
"repetition": -1,
"of_val": 1.0,
"optimum_interval_days": 0,
"afs": [],
"af": 0.0,
"real_rept": 0,
"rept": 0,
"interval": 0,
"last_date": 0,
"next_date": 0,
"is_activated": 0,
# 毫秒精度字段
"last_date_ms": 0,
"next_date_ms": 0,
"last_modify": get_timestamp(),
}
@classmethod
def _algodata_to_item(cls, algodata, sm):
data = algodata.get(cls.algo_name, cls.defaults.copy())
item = Item(sm)
item.repetition = data.get("repetition", -1)
item.lapse = data.get("lapse", 0)
item.of = data.get("of_val", 1.0)
item._afs = list(data.get("afs", []))
af = data.get("af", 0.0)
if af > 0:
item.af(af)
if item._afs:
if item._af is None and item._afs:
item.af(sum(item._afs) / len(item._afs))
opt_days = data.get("optimum_interval_days", 0)
item.optimum_interval = (
opt_days * 24 * 60 * 60 * 1000 if opt_days > 0 else sm.interval_base
)
# 毫秒精度优先, 退化至天精度
last_date_ms = data.get("last_date_ms", 0)
if last_date_ms:
item.previous_date = datetime.datetime(1970, 1, 1) + datetime.timedelta(
milliseconds=last_date_ms
)
else:
last_date = data.get("last_date", 0)
item.previous_date = (
daystamp_to_datetime(last_date).replace(tzinfo=None)
if last_date > 0
else None
)
next_date_ms = data.get("next_date_ms", 0)
if next_date_ms:
item.due_date = datetime.datetime(1970, 1, 1) + datetime.timedelta(
milliseconds=next_date_ms
)
else:
next_date = data.get("next_date", 0)
item.due_date = (
daystamp_to_datetime(next_date).replace(tzinfo=None)
if next_date > 0
else datetime.datetime(1970, 1, 1)
)
item.value = {"_restored": True}
return item
@classmethod
def _item_to_algodata(cls, item, algodata):
if cls.algo_name not in algodata:
algodata[cls.algo_name] = cls.defaults.copy()
data = algodata[cls.algo_name]
data["lapse"] = item.lapse
data["repetition"] = item.repetition
data["of_val"] = item.of
opt_ms = max(item.optimum_interval, 0)
data["optimum_interval_days"] = round(opt_ms / (24 * 60 * 60 * 1000))
data["afs"] = list(item._afs)
data["af"] = item.af() if item._af is not None else 0.0
# 毫秒精度
if item.previous_date:
data["last_date_ms"] = int(item.previous_date.timestamp() * 1000)
data["last_date"] = datetime_to_daystamp(item.previous_date)
data["next_date_ms"] = int(item.due_date.timestamp() * 1000)
data["next_date"] = datetime_to_daystamp(item.due_date)
data["interval"] = max(0, data["next_date"] - (data.get("last_date", 0) or 0))
data["last_modify"] = get_timestamp()
return algodata
@classmethod
def revisor(
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
):
logger.debug(
"SM-15M.revisor, feedback=%d, is_new_activation=%s",
feedback,
is_new_activation,
)
if feedback == -1:
return
sm = _get_global_sm()
item = cls._algodata_to_item(algodata, sm)
if is_new_activation:
item.repetition = -1
item.lapse = 0
item.of = 1.0
item.optimum_interval = sm.interval_base
item.previous_date = None
item.due_date = datetime.datetime(1970, 1, 1)
item._afs = []
item._af = None
item.af(2.5)
sm.answer(feedback, item)
_save_global_sm(sm)
cls._item_to_algodata(item, algodata)
algodata[cls.algo_name]["real_rept"] += 1
if feedback >= THRESHOLD_RECALL:
algodata[cls.algo_name]["rept"] += 1
logger.debug(
"SM-15M.revisor: repetition=%d, of=%.4f, next_date=%d",
item.repetition,
item.of,
algodata[cls.algo_name]["next_date"],
)
@classmethod
def is_due(cls, algodata):
data = algodata.get(cls.algo_name, cls.defaults.copy())
# 毫秒精度优先
next_date_ms = data.get("next_date_ms", 0)
if next_date_ms:
result = next_date_ms <= get_timestamp_ms()
logger.debug(
"SM-15M.is_due: next_date_ms=%d, now_ms=%d, result=%s",
next_date_ms,
get_timestamp_ms(),
result,
)
return result
# 退化至天精度
next_date = data.get("next_date", 0)
current = get_daystamp()
result = next_date <= current
logger.debug(
"SM-15M.is_due: next_date=%d, current=%d, result=%s",
next_date,
current,
result,
)
return result
@classmethod
def get_rating(cls, algodata):
data = algodata.get(cls.algo_name, cls.defaults.copy())
af = data.get("af", 0.0)
logger.debug("SM-15M.get_rating: af=%f", af)
return str(af)
@classmethod
def nextdate(cls, algodata) -> int:
data = algodata.get(cls.algo_name, cls.defaults.copy())
n = data.get("next_date", 0)
logger.debug("SM-15M.nextdate: %d", n)
return n

Some files were not shown because too many files have changed in this diff Show More