4 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
107 changed files with 6482 additions and 1516 deletions
+33 -33
View File
@@ -1,50 +1,50 @@
# AI 编程工具说明
# AI Coding Assistant Guide
本文档为 AI 工具以及在使用 AI 辅助向 HeurAMS 项目贡献代码的开发者提供指导, 一般而言此文件会被自动读入多种 AI 工具的上下文.
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 工具应当完整阅读此 `/AGENTS.md` 文件.
AI tools should read this `/AGENTS.md` file in its entirety.
## 查阅开发文档
## Reference Documents
在帮助进行 HeurAMS 开发时,AI 工具应遵循标准的开发规范与流程, 应当自动查看或是在用户发出"初始化/init"指令后查看:
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.md)
- [自述文件](/README.md)
- [项目架构](/ARCHITECTURE.md)
- [Contributing Guide](/CONTRIBUTING.md)
- [README](/README.md)
- [Architecture Overview](/docs/ARCHITECTURE.md)
## 明确禁止行为
## Prohibited Actions
1. 禁止 AI 自动生成 PR patch 文件
2. 禁止 AI 在未经人工确认的情况下修改现有代码
3. 禁止 AI 不使用格式化工具而生成格式化文件的行为
4. 禁止 AI 修复任何"bug", 而不经人工确认
5. 禁止一切不遵循项目设计原则, 另造独立库的 "糊屎" 行为
6. 禁止 AI 直接操作 pip, uv, apt 等工具修改外部依赖或工具, 而应让人类开发者自己操作依赖
7. 禁止使用不同于任何现有文件的现有注释语言的其他语言写新注释
8. 禁止不读文件就直接覆写
9. 绝对禁止修改此 `/AGENTS.md` 文件
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
所有贡献必须符合许可要求, 所有代码必须与 AGPL-3.0-or-later 许可以及项目附加豁免条款(位于 LICENSE 文件尾部 237245 行)兼容.
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
## Signed-off-by & DCO
AI 代理**严禁添加** Signed-off-by 标签.
AI agents are **strictly forbidden** from adding Signed-off-by tags.
只有人类能够合法地认证 DCO.
Only humans can legally certify the DCO.
人类提交者负责:
Human committers are responsible for:
- 审阅所有 AI 生成的代码
- 确保符合许可要求
- 添加自己的 Signed-off-by 标签以认证 DCO
- 对贡献负责任
- 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 助手负责:
AI assistants are responsible for:
- 了解运行环境, 例如操作系统或具体发行版
- 遵循此文档所述规则
- 主动提醒使用 AI 工具的开发者
- 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
本文档参考自 <a href="https://docs.kernel.org/process/coding-assistants.html" target="_blank" rel="noopener noreferrer">AI Coding Assistants — The Linux Kernel documentation</a>
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>
+61 -61
View File
@@ -1,119 +1,119 @@
# 贡献指南与二次开发
# Contributing Guide & Development
欢迎支持此项目!
Welcome and thank you for supporting this project!
目前, 项目仓库主服务器为<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> 设置了镜像同步.
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>.
这丝毫不影响项目接受来自 <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 会保留贡献者标识并按原样同步回所有平台, 欢迎在任意平台为项目做出贡献.
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]
> 我们已经开始着手于基于 KDE 用户界面框架 `Kirigami` 的现代跨平台前端开发, 称作 "KiriMemo", 包名是 "org.kde.kirimemo", 但其并非 KDE 项目\
> 它通过 `PyOtherSide` 直接复用 python 内核, 为 Windows, Linux, macOS, Android, iOS Plasma Mobile 提供现代用户界面
> 如果您善于开发 C++, QML, Qt 与 KDE 框架, 欢迎加入到 KiriMemo 项目的开发
> 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` 分支(仓库默认分支): 主线开发分支, 自身仅用于非重构的问题修复和整合功能分支, 拉取请求在该分支合并
- `master` 分支: 主线稳定版本, 仅当稳定版本释出或修补版本时将 `dev` 合并到 `master`
- 功能与重构分支: 从 `dev` 分支创建, 命名格式为 `feature/描述` `fix/描述` `refactor/描述` `next/版本号`
- 功能与重构分支应先合并至 `dev`, 再合并至 `master`
- `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
```
- 对于 Python, 使用 `black` `autoflake` 格式化\
命令:
- For Python, use `black` and `autoflake`.\
Commands:
```bash
# black 的多线程在某些环境下有兼容性问题
# black's multi-threading may have compatibility issues in some environments
black . --workers=1
```
```bash
# autoflake 注意排除 __init__.py
# autoflake: note to exclude __init__.py
autoflake --in-place --remove-all-unused-imports --recursive ./src/ --exclude __init__.py
```
- 对于 Markdown, 使用 `mdformat` 格式化
命令:
- For Markdown, use `mdformat`.\
Command:
```bash
mdformat --number .
```
- 对于 Textual CSS, 可以使用 `prettier` 格式化
- 格式化不是必需的, 可以整合入一次 `style` 提交, 但 `master` `dev` 分支上的代码应尽量整洁, 以便合并时审查
- 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:
- 使用简体中文或英文撰写清晰的提交消息
- 提交消息格式: 遵循 [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 规范, 建议使用 `koji` 工具
- 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:
- 为了一致性和可追溯性, 项目自 v0.4.0 重构后重新初始化仓库起就禁止使用 Fast-forward 合并
- 可以设置 `git config merge.ff false`
- 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`.
提交署名方式:
由于 KDE Invent 设施的奇怪 git hook, commit Author 字段需要看起来像个真名(例如 Wang Zhiyu 不能写为 wangzhiyu, 否则 KDE Invent hook 会拒绝 push),
所以请确保您的 git 配置使用了类似正式姓名的格式 (例如 git config user.name "Li Hua", 也即中间需要空格), 不一定要真实姓名, 邮箱无要求, 也可以将单名重复使用两次 (例如 Thura Thura) 以通过检测.
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://git.pluv27.top/pluv/HeurAMS # 默认分支为 dev, 所以不必切换分支
git clone https://git.pluv27.top/pluv/HeurAMS # Default branch is dev, no need to switch
cd HeurAMS
# 如果决定使用 uv (推荐)
# If using uv (recommended)
python3 -m pip install uv
uv sync --all-extras # 同步开发运行环境
uv sync --all-extras # Sync development environment
uv run heurams
# 如果决定使用原生 python 环境 (不推荐, 但我们保留了这种方式以便在不便支持 uv 与硬链接的环境和文件系统(例如 termux)上运行)
# 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] # 安装依赖并将 HeurAMS 安装为本地包
python3 -m pip install -e .[all] # Install dependencies and HeurAMS as a local package
python3 -m heurams # 验证安装
python3 -m heurams # Verify installation
python3 -m heurams.interface # 启动 TUI
python3 -m heurams.interface # Launch TUI
```
## 许可证与外部引用
## License & External References
贡献者拥有其贡献部分的版权同意其贡献将在 AGPL-3.0 许可证(包括附加的本机 API 调用豁免条款)下发布.
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).
如有以下情况, 请在 PR 描述中注明:
Please note in your PR description if:
- 如果需要引入其他开源 vendor
- 如果需要引入其他专有的网络服务(例如当前项目中的 edgetts)
- 如果需要升级某个依赖或运行环境的版本
- 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 被设计为一个可独立于前端的程序库, 这意味着:
HeurAMS is designed as a frontend-independent library, meaning:
- 我们的内置 Textual TUI 前端不是唯一可用的前端
- Our built-in Textual TUI frontend is not the only available frontend.
- 如果您有一个自己开发的且可用的 HeurAMS 前端 (例如未实现的 Flutter 前端), 并且以 AGPL-3.0/GPL-3.0 开放源代码, 可以联系我们将它转移到 HeurAMS 的官方仓库中以便共同维护, 您将保留您的版权并可主导该仓库下的开发工作 :)
- 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. :)
- 您还可以在自己的项目中以独立进程/服务调用 HeurAMS, 根据 AGPL-3.0 及本项目的附加许可条款, 如果调用发生在同一主机上且不涉及外部网络转发, 则可豁免许可证规定的特定义务而免于受 AGPL-3.0 "污染". 为了这点, 我们正在完善可选择启用的跨进程 RPC 模块, 这将成为潜进内核的跨平台标准件.
- 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.
- 如果您通过独立进程/服务调用方式开发了另外的软件, 开源但不愿使用 AGPL-3.0/GPL-3.0 许可证, 也可以联系我们, 我们乐于将您的项目链接添加到友链中
- 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 许可证, 也可以联系我们, 我们乐于将您的项目链接添加到友链中
## 软件开发之外的贡献
即使您不是软件开发人员, 我们也欢迎您加入贡献!
您可以:
- 协助创建或核对各种语言的翻译来翻译软件的界面和文档
- 制作开放的记忆单元集(包括但不限于文字、图像、音效)给其他用户使用
- 改进软件配套的文档
- 给其他用户答疑解惑或分享自己的经验
- 在讨论区提出新想法或反馈问题
您的角色您来定!
+65 -64
View File
@@ -1,142 +1,143 @@
# 潜进 (HeurAMS) - 启发式辅助记忆调度器
# HeurAMS - Heuristic Auxiliary Memorizing Scheduler
## 概述
[中文](README_zh.md) | English
"潜进" (HeurAMS: Heuristic Auxiliary Memorizing Scheduler, 启发式记忆辅助调度器) 是一种基于启发式算法与认知科学理论的辅助记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划,
也是一种开放, 优雅, 易于扩展的间隔重复调度器实验平台, 旨在帮助研究者更高效地进行前沿记忆算法的调查实验与研究.
## Overview
[详细介绍](INTRODUCTION.md) [屏幕截图](SCREENSHOTS.md)
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)
<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>
## 快速开始
## Quick Start
### 安装软件
### Installation
#### 从包管理器安装
#### Install from Package Manager
潜进 (包名是 `heurams`) 处于早期开发考虑, 尚未上架 PyPI.
但可以用 pip 从仓库安装稳定版和开发版本, 这要求设备上安装了 python 环境 (建议 3.12.13 及之后版本).
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).
从稳定的 `master` 分支安装, 并安装适用于用户体验的可选依赖(推荐):
Install from the stable `master` branch with optional dependencies for user experience (recommended):
```
pip install --upgrade 'heurams[basic] @ https://git.pluv27.top/pluv/HeurAMS/archive/master.zip'
```
从较前沿, 大致稳定的 `dev` 分支安装, 并安装适用于用户体验的可选依赖(如果您追求较前沿的改进):
Install from the more recent, roughly stable `dev` branch with optional dependencies (if you want cutting-edge improvements):
```
pip install --force-reinstall --no-deps 'heurams[basic] @ https://git.pluv27.top/pluv/HeurAMS/archive/dev.zip'
```
安装适用于一般计算机的通用音频模块 (基于 playsound3):\
(此项不适用于 termux 环境, termux 的音频支持是内建的)
Install the general audio module for desktop computers (based on playsound3):\
(Not applicable for termux environments; termux has built-in audio support)
```
pip install --upgrade 'heurams[audio-playsound] @ https://git.pluv27.top/pluv/HeurAMS/archive/master.zip'
```
> 您也可以从 `refactor/...` 等特定分支安装以测试某项更改
> You can also install from specific branches like `refactor/...` to test particular changes.
[依赖分组说明](INTRODUCTION.md#包依赖组说明)
[Dependency Group Reference](docs/INTRODUCTION.md#package-dependency-groups)
#### 从源码安装
#### Install from Source
我们提供原生 python 和 uv 两种源码安装方式.\
详见[贡献指南 - 设置开发环境](CONTRIBUTING.md#设置开发环境).
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
在终端中运行 `heurams`, 您会看到一系列帮助信息, 例如:
Run `heurams` in your terminal, and you will see help information:
```plain
~ $ heurams
Usage: heurams [OPTIONS] COMMAND [ARGS]...
HeurAMS 0.5.1 - 启发式辅助记忆调度器
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 显示此帮助信息
tui 启动内置基本用户界面 (TUI)
version 输出版本信息
help Show this help message
tui Launch the built-in basic user interface (TUI)
version Print version information
```
可以通过键入 `heurams tui` 启动基本用户界面, 例如:
Start the basic user interface by typing `heurams tui`:
```plain
~ $ heurams tui
欢迎使用基本用户界面!
加载配置与上下文... 已完成! (耗时: 33ms)
加载用户界面框架... 已完成! (耗时: 169ms)
加载用户界面布局... 已完成! (耗时: 165ms)
组件目录: <软件包所在目录>
工作目录: <运行目录, 将在此目录下建立 ./data 文件夹>
前置工作共计耗时: 142ms
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
(此时您的终端将转为呈现美观的 TUI 基本用户界面)
(Your terminal will now display the TUI)
```
通过键入 `heurams -v` 查看版本:
Check the version with `heurams -v`:
```
~ $ heurams -v
HeurAMS 0.5.1 stable (fulcrum/支点), Linux
```
## 常见问题 (FAQ)
## Frequently Asked Questions (FAQ)
详见[常见问题](FAQ.md).
See [FAQ](docs/FAQ.md).
## 项目架构
## Project Architecture
详见[架构说明](ARCHITECTURE.md).
See [Architecture Overview](docs/ARCHITECTURE.md).
## 参与项目
## Contributing
欢迎参与到项目协作中!\
详见[贡献指南](CONTRIBUTING.md).\
关于 AI 辅助开发的说明, 请参阅 [AGENTS.md](AGENTS.md).
Contributions are welcome!
See the [Contributing Guide](CONTRIBUTING.md).
For AI-assisted development guidelines, see [AGENTS.md](AGENTS.md).
## 项目标识
## Project Identity
HeurAMS 项目标识如下, 文件(位图和矢量图)位于 `./src/heurams/assets/art/` 目录.
HeurAMS project identity assets are located in `./src/heurams/assets/art/` directory:
<img src="src/heurams/assets/art/banner128-light.png" height="96px" title="位图横幅(不透明)">
<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="矢量图标">
<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="单色暗色矢量图标">
<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>
颜色分别是: `#1660A5 (海蓝色)` `#545F70 (蓝灰色)` `#FFFFFF (单色明亮图标白色)` `#1A1A1A (单色暗色图标深黑色)` `#2f2f35 (文字颜色)`.
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.
本项目基于 AGPL-3.0 许可证开放源代码, 并有一个豁免本机 API 调用的附加条款, 较标准 AGPL-3.0 更宽松.
See the [LICENSE](LICENSE) file in the root directory.
详见根目录下 [LICENSE](LICENSE) 文件.
### Third-Party Code
### 第三方代码
项目在 `src/heurams/vendor/` 目录下嵌入或在其他位置直接使用了以下第三方代码或其衍生作品 (可能有修改):
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
- 上游版本: 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
- 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
本项目受益于他们无私且优秀的工作.
+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
```
+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.
+2 -2
View File
@@ -333,7 +333,7 @@ Flutter 是构建跨平台图形界面的优秀框架, HeurAMS 的设计目标
当前我们优先开发了基于 Textual 的 TUI 前端和基于 Kirigami 的原生前端, 但这不排除未来出现 Flutter 或其他框架前端的可能性.
如果您有兴趣开发 Flutter 前端, 欢迎参考[贡献指南](CONTRIBUTING.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).
如果您有兴趣开发 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).
## 软件需要联网吗?
@@ -411,6 +411,6 @@ HeurAMS 的间隔重复算法基于相同的认知科学原理, 且算法透明
## 如何参与项目?
详见[贡献指南](CONTRIBUTING.md).
详见[贡献指南](../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.
+3 -3
View File
@@ -44,7 +44,7 @@
- 资源占用小, 运行流畅, 不拖泥带水
- 便于测试与调试程序库
查看[屏幕截图](SCREENSHOTS.md).
查看[屏幕截图](SCREENSHOTS_zh.md).
## 包依赖组说明
@@ -66,11 +66,11 @@
## 关于此仓库
此仓库为 "潜进" 的核心程序库在 python 语言下的实现\
此仓库为 HeurAMS "潜进" 的核心程序库在 python 语言下的实现\
包含数据模型与框架, 并内置了基于 textual 框架的前端实现 (interface 子模块)\
除了通过内置前端进行学习外, 开发者也能在 python 环境中导入 `heurams` 库或使用 `RPC``heurams` 程序库实例通讯, 使用框架构建其他辅助记忆功能前端或其他应用程序
潜进项目的所有仓库如下:
项目的所有仓库如下:
| 项目名称 | 状态 | 说明 | 包名 | 技术栈 | 目标平台 |
| :--- | :--- | :--- | :--- | :--- | :--- |
+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 -->
+13 -13
View File
@@ -7,7 +7,7 @@
<!--- ArkMemo (top.pluv27.arkmemo): 基于 ArkUI 的现代移动设备前端, 使用 ArkTS 构建, 通过 API 调用 Python 内核, 为 Android, HarmonyOS, iOS 平台提供原生体验 (尚未稳定)-->
欢迎为现有前端贡献代码, 或开发您自己的前端.\
详见[贡献指南](CONTRIBUTING.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).
详见[贡献指南](../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).
## 基本用户界面前端的截图
@@ -21,9 +21,9 @@
导航器是一个实用的模态窗口, 能带您在多种功能间自如切换, 按 `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%">
<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>
### 准备界面与预缓存工具
@@ -32,8 +32,8 @@
预缓存工具使您能提前预缓存文本转语音资源以确保复习流程的顺畅体验和离线复习能力, 但即使您不预先缓存, 资源也会在复习播放时被自动加载.
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
<img src="screenshots/preparation.png" width="48%">
<img src="screenshots/precache_1.png" width="48%">
<img src="../screenshots/preparation.png" width="48%">
<img src="../screenshots/precache_1.png" width="48%">
</div>
### 记忆队列界面
@@ -42,9 +42,9 @@
同一知识点可产生多种谜题类型的评估方式, 软件内置完形填空与识别题等多种测试类型, 您可在复习流程中按顺序完成不同测试.
<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%">
<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>
### 设置界面
@@ -52,8 +52,8 @@
配置界面包含算法选择、音频与多种服务的提供者切换、以及界面与算法设置等选项.
<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%">
<img src="../screenshots/setting_1.png" width="48%">
<img src="../screenshots/setting_2.png" width="48%">
</div>
### 其他界面
@@ -62,8 +62,8 @@
关于页面提供了程序版本号、许可协议等信息.
<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%">
<img src="../screenshots/about_1.png" width="48%">
<img src="../screenshots/favmanager_1.png" width="48%">
</div>
## KiriMemo 前端的截图
+7
View File
@@ -14,9 +14,11 @@ license-files = ["LICENSE"]
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]
@@ -74,3 +76,8 @@ markers = [
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"httpx>=0.28.1",
]
+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")
+78 -24
View File
@@ -1,64 +1,118 @@
import platform
"""命令行入口
"""
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,
help=(
f"HeurAMS {ver} - 启发式辅助记忆调度器"
),
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):
def cli(ctx, locale):
if ctx.invoked_subcommand is None:
click.echo(cli.get_help(ctx))
ctx.exit(0)
@cli.command()
@cli.command(cls=_I18nCommand)
def tui():
"""启动内置基本用户界面 (TUI)"""
"""Launch the built-in user interface (TUI)"""
import heurams.interface.__main__ as tui_module
tui_module.main()
def _print_version():
@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(
f"HeurAMS {ver} ({codename}/{codename_cn}), 阶段: {stage}"
_("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()
def version():
"""输出版本信息"""
_print_version()
@cli.command(name="ver", hidden=True)
def ver_cmd():
"""输出版本信息"""
_print_version()
@cli.command(name="help")
@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()
+7 -6
View File
@@ -1,6 +1,6 @@
"""
全局上下文管理模块
以及基准路径
"""全局上下文模块
初始化并管理基准路径, 程序配置对象, 并提供调试所需上下文管理器
"""
import pathlib
@@ -19,13 +19,14 @@ workdir = pathlib.Path.cwd()
"""工作目录路径."""
logger = get_logger(__name__)
logger.debug(f"包目录: {rootdir}")
logger.debug(f"工作目录: {workdir}")
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("初始化数据目录: %s", user_data)
logger.info("Create a new data directory: %s", user_data)
import shutil
shutil.copytree(default_data, user_data)
+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()
+20 -19
View File
@@ -2,52 +2,53 @@ from time import sleep, perf_counter
# import gc
# gc.set_threshold(100, 1, 1)
print("欢迎使用基本用户界面!")
print("加载配置与上下文... ", end="", flush=True)
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(f"已完成! (耗时: {round(1000 * (_end - _start))}ms)")
print(_("Done! ({time}ms)").format(time=round(1000 * (_end - _start))))
print("加载用户界面框架... ", end="", flush=True)
print(_("Loading UI framework... "), end="", flush=True)
_start = perf_counter()
from textual.app import App
from textual.widgets import Button
_end = perf_counter()
print(f"已完成! (耗时: {round(1000 * (_end - _start))}ms)")
print(_("Done! ({time}ms)").format(time=round(1000 * (_end - _start))))
print("加载用户界面布局... ", end="", flush=True)
print(_("Loading UI layout... "), end="", flush=True)
_start = perf_counter()
from .screens.about import AboutScreen
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(f"已完成! (耗时: {round(1000 * (_end - _start))}ms)")
print(_("Done! ({time}ms)").format(time=round(1000 * (_end - _start))))
print(f"组件目录: {rootdir}")
print(f"工作目录: {workdir}")
print(_("Component directory: {path}").format(path=rootdir))
print(_("Working directory: {path}").format(path=workdir))
_end_all = perf_counter()
print(f"前置工作共计耗时: {round(1000 * (_end_all - _start_all))}ms")
print(_("Pre-work total: {time}ms").format(time=round(1000 * (_end_all - _start_all))))
class HeurAMSApp(App):
TITLE = "潜进"
TITLE = "HeurAMS"
CSS_PATH = rootdir / "interface" / "css" / "main.tcss"
SUB_TITLE = "启发式辅助记忆调度器"
SUB_TITLE = _("Heuristic Auxiliary Memorizing Scheduler")
BINDINGS = [
("q", "go_back", "退出"),
("d", "toggle_dark", "主题"),
("n", "app.push_screen('navigator')", "导航"),
("s", "app.push_screen('setting')", "设置"),
("z", "app.push_screen('about')", "关于"),
("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,
+2 -1
View File
@@ -1,5 +1,6 @@
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
@@ -21,7 +22,7 @@ def start_debug_server(app):
code = pickle.loads(msg)
namespace = {"app": app, "logger": logger, "config_var": config_var}
if first:
app.title += " [调试已连接]"
app.title += _(" [Debug Connected]")
first = 0
try:
# 先尝试 eval
+47 -39
View File
@@ -1,4 +1,4 @@
"""关于界面"""
"""About screen"""
from textual.app import ComposeResult
from textual.containers import ScrollableContainer
@@ -8,6 +8,7 @@ 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
@@ -16,10 +17,10 @@ import sys
class AboutScreen(Screen):
BINDINGS = [
("q", "go_back", "返回"),
("z", "go_back", "关于"),
("q", "go_back", _("Back")),
("z", "go_back", _("About")),
]
SUB_TITLE = "关于"
SUB_TITLE = _("About")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -37,55 +38,64 @@ class AboutScreen(Screen):
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="about_container"):
yield Label("[b]关于与版本信息[/b]")
# 获取系统信息
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()
about_text = f"""
# 关于 HeurAMS "潜进"
about_text = _(
"""# About HeurAMS
主程序库版本: `{version.ver}-python`
用户界面分支: `Textual TUI (基本用户界面)`
用户界面版本: `{version.ver}`
API 版本代号: `{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.
您可在项目主页 https://ams.pluv27.top 获取用户指南, 开发文档与软件更新, 并参与到软件的开发与改进工作.
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.
以 GNU Affero 通用公共许可证 (第3版) 开放源代码, 并有一条豁免本机 API 调用的附加条款, 用于其他前端到程序库的接口调用.
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.
您正使用程序库内置的终端用户界面, 它是第一个全功能前端实现与程序库测试套件, 位于程序库的 interface 子目录.
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.
开发人员列表:
- Wang Zhiyu ([@pluvium27](https://github.com/pluvium27)): 项目发起与主要开发者
Developers:
- Wang Zhiyu ([@pluvium27](https://github.com/pluvium27)): Project initiator and lead developer
感谢以下人士与团体, 他们的算法与理论构成了此软件现有算法的基石:
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 算法与 SM-15 算法理论
- [Jarrett Ye](https://github.com/L-M-Sherlock): FSRS 算法与间隔重复理论文献参考
- [Kazuaki Tanida](https://github.com/slaypni): SM-15 算法的 CoffeeScript 逆向实现
- [Open Spaced Repetition](https://github.com/open-spaced-repetition): FSRS 算法底层实现
- [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 解释器版本: {python_version}
Python 解释器路径: {sys.executable}
Textual 框架版本: {textual_version}
终端模拟器: {terminal_info}
操作系统版本: {os_version}
存储余量: {disk_usage}
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}
报告问题时, 请复制这些信息到问题描述, 并上传软件日志 `heurams.log` 作为附件, 以协助开发者定位错误
"""
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,
@@ -102,22 +112,20 @@ Textual 框架版本: {textual_version}
self.action_go_back()
def _get_textual_version(self) -> str:
"""获取 Textual 框架版本"""
try:
import textual
return textual.__version__
except (ImportError, AttributeError):
return "未知"
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 "未知"
return terminal_env or _("Unknown")
def _get_python_version(self) -> str:
"""获取 Python 解释器版本"""
+46 -23
View File
@@ -1,4 +1,4 @@
"""仪表盘界面"""
"""Dashboard screen"""
from functools import reduce
from pathlib import Path
@@ -14,6 +14,7 @@ import heurams.kernel.particles as pt
import heurams.services.timer as timer
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
@@ -25,11 +26,11 @@ logger = get_logger(__name__)
class DashboardScreen(Screen):
"""主仪表盘屏幕"""
"""Main dashboard screen"""
SUB_TITLE = "仪表盘"
SUB_TITLE = _("Dashboard")
BINDINGS = [
("q", "go_back", "返回"),
("q", "go_back", _("Back")),
]
CSS_PATH = rootdir / "interface" / "css" / "screens" / "dashboard.tcss"
@@ -46,45 +47,50 @@ class DashboardScreen(Screen):
self._load_data()
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 Horizontal( # 顶部的状态
yield Horizontal(
Vertical(
Label(f"当前日时间戳: {timer.get_daystamp()}"),
Label(_("Current daystamp: {ds}").format(ds=timer.get_daystamp())),
Label(
f"应用时区修正: UTC+{str(config_var.get()['services']['timer']['timezone_offset'] / 3600).removesuffix('.0')}"
_("Timezone offset: UTC+{offset}").format(offset=str(config_var.get()['services']['timer']['timezone_offset'] / 3600).removesuffix('.0'))
),
Label(
f"默认算法设置: {config_var.get()['interface']['global']['algorithm']}",
_("Default algorithm: {algo}").format(algo=config_var.get()['interface']['global']['algorithm']),
),
classes="left",
),
Vertical(
Label(f"已加载 {len(self.repos)} 个单元集"),
Label(_("Loaded {n} repo(s)").format(n=len(self.repos))),
Label(
f"共计 {reduce(lambda x, y: x + y, map(lambda x: x.progress['total'], self.repos)) if self.repos else 0} 个单元"
_("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(
f"已激活 {reduce(lambda x, y: x + y, map(lambda x: x.progress['touched'], self.repos)) if self.repos else 0} 个单元"
_("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") # 单元集选择
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(f"版本 {version.ver}-{version.stage}") # 版本信息
yield Label(_("Version {ver}-{stage}").format(ver=version.ver, stage=version.stage))
yield Label(
f"{round(a.data['totaltime'], 2)} 秒内处理了 {a.data['openpuzzles']} 个谜题, 正确率{'无法求解' if not a.data['openpuzzles'] else ' ' + str(round(100 * (1 - a.data['puzzles_err']/a.data['openpuzzles']), 2)) + '%'}, 平均速度{'无法求解' if not a.data['totaltime'] else ' ' + str(round(a.data['openpuzzles']/a.data['totaltime'], 2)) + ' 个每秒'}",
_("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()
@on(events.ScreenResume)
@@ -140,9 +146,28 @@ class DashboardScreen(Screen):
repo.preview["review"] += 1
# initial_time = min(initial_time, e.)
repo.need_review = timer.get_daystamp() >= repo.nearest_review_time
repo.prompt = f"""{repo.manifest['title']} \\[{repo.config['algorithm']}]
[d]进度: {repo.progress['touched']}/{repo.progress['total']} ({round(repo.progress['touched']/repo.progress['total']*100, 1)}%)[/d]
[d]{f'需要学习: {repo.preview['review']}R + {repo.preview['new']}U' if repo.need_review else (f"暂未开始: 0R + {repo.preview['new']}U" if not repo.progress['have_activated_ever'] else '无需操作')}[/d]"""
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")
)
),
)
def on_mount(self) -> None:
"""挂载组件时初始化"""
@@ -160,8 +185,7 @@ class DashboardScreen(Screen):
repo_list_widget.append(
ListItem(
Static(
f"{config_var.get()['global']['paths']['repo']} 中未找到任何单元集仓库目录.\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",
)
@@ -175,7 +199,7 @@ class DashboardScreen(Screen):
list_item = ListItem(
*[Label(line) for line in r.prompt.splitlines()],
Button(
f"开始学习",
_("Start Learning"),
flat=True,
variant="primary",
id=f"slaunch_repo_{r.manifest['package']}",
@@ -210,7 +234,6 @@ class DashboardScreen(Screen):
def on_button_pressed(self, event: Button.Pressed) -> None:
"""处理按钮点击事件"""
logger.debug(f"event.button.id: {event.button.id}")
if event.button.id.startswith("slaunch_repo_"): # type: ignore
from .preparation import launch
+46 -45
View File
@@ -1,4 +1,4 @@
"""收藏夹管理器界面"""
"""Favorites manager screen"""
import base64
from pathlib import Path
@@ -21,6 +21,7 @@ from textual.widgets import (
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
@@ -29,12 +30,12 @@ logger = get_logger(__name__)
class FavoriteManagerScreen(Screen):
"""收藏夹管理器屏幕"""
"""Favorites manager screen"""
SUB_TITLE = "收藏夹"
SUB_TITLE = _("Favorites")
BINDINGS = [
("q", "go_back", "返回"),
("q", "go_back", _("Back")),
("d", "toggle_dark", ""),
]
@@ -49,12 +50,12 @@ class FavoriteManagerScreen(Screen):
self._load_favorites()
def _load_favorites(self) -> None:
"""加载收藏列表"""
"""Load favorites list"""
self.favorites = favorite_manager.get_all()
logger.debug("加载 %d 个收藏项", len(self.favorites))
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(
@@ -62,10 +63,10 @@ class FavoriteManagerScreen(Screen):
)
with ScrollableContainer(id="favorites-container"):
if not self.favorites:
yield Label("暂无收藏", classes="empty-label")
yield Static("使用 * 键在记忆界面中添加收藏.")
yield Label(_("No favorites"), classes="empty-label")
yield Static(_("Press * in the memorization screen to add favorites."))
else:
yield Label(f"{len(self.favorites)} 个收藏项", classes="count-label")
yield Label(_("Total {n} favorite(s)").format(n=len(self.favorites)), classes="count-label")
yield ListView(id="favorites-list")
yield Footer()
@@ -83,41 +84,41 @@ class FavoriteManagerScreen(Screen):
list_view.append(self._create_favorite_item(fav)) # type: ignore
def _encode_favorite_key(self, repo_path: str, ident: str) -> str:
"""编码仓库路径和标识符为安全的按钮 ID 部分"""
# 使用 \x00 分隔两部分, 然后进行 base64 编码
"""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]:
"""解码按钮 ID 部分为仓库路径和标识符"""
# 补全等号以使长度是4的倍数
"""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 += f" [d]添加于: {added_time}\n 来自 {title}[/d]"
display_text += _(" [d]Added: {time}\n From {title}[/d]").format(time=added_time, title=title)
if fav.tags:
display_text += f"{', '.join(fav.tags)}"
# 创建安全的按钮 ID
# 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,
@@ -128,21 +129,21 @@ class FavoriteManagerScreen(Screen):
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("仓库目录不存在: %s", repo_dir)
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
# 查找对应 ident 的 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:
@@ -157,53 +158,53 @@ class FavoriteManagerScreen(Screen):
"content_preview": content_preview,
}
except Exception as e:
logger.error("获取仓库信息失败: %s", 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-"):
# 提取编码后的键
key = button_id[7:] # 去掉 "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("解析按钮 ID 失败: %s", e)
self.app.notify("操作失败: 无效的按钮标识", severity="error")
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(f"已移除收藏: {ident}", severity="information")
# 重新加载列表
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(f"移除失败: {ident}", severity="error")
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("暂无收藏", classes="empty-label"))
container.mount(Static("使用 * 键在记忆界面中添加收藏. "))
container.mount(Label(_("No favorites"), classes="empty-label"))
container.mount(Static(_("Press * in the memorization screen to add favorites.")))
else:
container.mount(
Label(f"{len(self.favorites)} 个收藏项", classes="count-label")
Label(_("Total {n} favorite(s)").format(n=len(self.favorites)), classes="count-label")
)
list_view = ListView(id="favorites-list")
container.mount(list_view)
+31 -31
View File
@@ -1,4 +1,4 @@
"""队列式记忆工作界面"""
"""Queue-based memorization screen"""
from pathlib import Path
@@ -12,6 +12,7 @@ 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
@@ -24,11 +25,11 @@ logger = get_logger(__name__)
class MemScreen(Screen):
BINDINGS = [
("q", "go_back_notif", "返回"),
("p", "prev", "查看上一个"),
("q", "go_back_notif", _("Back")),
("p", "prev", _("Previous")),
("d", "toggle_dark", ""),
("v", "play_voice", "朗读"),
("*", "toggle_favorite", "收藏"),
("v", "play_voice", _("Read Aloud")),
("*", "toggle_favorite", _("Favorite")),
("r", "resume_mark"),
("Q", "go_back"),
("n", "block_prompt"),
@@ -36,12 +37,12 @@ class MemScreen(Screen):
("z", "block_prompt"),
]
SUB_TITLE = "学习中"
SUB_TITLE = _("Learning")
CSS_PATH = rootdir / "interface" / "css" / "screens" / "memoqueue.tcss"
if config_var.get()["interface"]["global"]["quick_pass"]:
BINDINGS.append(("k", "quick_pass", "正确应答"))
BINDINGS.append(("f", "quick_fail", "错误应答"))
BINDINGS.append(("k", "quick_pass", _("Correct")))
BINDINGS.append(("f", "quick_fail", _("Incorrect")))
rating = reactive(-1)
@@ -80,7 +81,7 @@ class MemScreen(Screen):
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
@@ -101,17 +102,17 @@ class MemScreen(Screen):
atom=self.atom, alia=puzzle["alia"] # type: ignore
)
except Exception as e:
logger.debug(f"调度展开出错: {e}")
return Static(f"无法生成谜题 {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 = "已收藏" if self._is_current_atom_favorited() else "未收藏"
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"
s += f"[{self.procession.process() + 1}/{self.procession.total_length()}] \\[{self.procession.route.name}]\n"
if self.procession.cursor - 1 >= 0:
s += f"上一个: [d]{self.procession.atoms[self.procession.cursor - 1]['ident']}[/d]"
s += _("Previous: {ident}").format(ident=f"[d]{self.procession.atoms[self.procession.cursor - 1]['ident']}[/d]")
return s
def update_display(self):
@@ -120,7 +121,7 @@ class MemScreen(Screen):
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
@@ -130,7 +131,7 @@ class MemScreen(Screen):
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")
@@ -153,7 +154,7 @@ class MemScreen(Screen):
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
@@ -161,7 +162,6 @@ class MemScreen(Screen):
path = Path(config_var.get()["global"]["paths"]["data"]) / "cache" / "voice"
path = path / f"{get_md5(self.atom.registry['nucleon']["tts_text"])}.wav"
logger.debug(str(path))
if path.exists():
play_by_path(path)
else:
@@ -205,7 +205,7 @@ class MemScreen(Screen):
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}")
logger.debug(f"Activated atom: {self.atom}")
self.atom.lock(1)
self.atom.minimize(5)
else:
@@ -228,7 +228,7 @@ class MemScreen(Screen):
self.expander = self.procession.get_expander()
def action_go_back_notif(self):
self.notify("确定吗? 按下大写 Q 以返回")
self.notify(_("Are you sure? Press uppercase Q to go back."))
def action_go_back(self):
self.app.pop_screen()
@@ -240,44 +240,44 @@ class MemScreen(Screen):
self.rating = 3
def _get_repo_rel_path(self) -> str:
"""获取仓库相对路径 (相对于 data/repo) """
"""Get repo relative path (relative to data/repo)"""
if self.repo is None:
return ""
# self.repo.source 是 Path 对象, 指向仓库目录
# 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:
# 如果不在 data/repo 下, 则返回完整路径 (字符串形式)
# 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("无法收藏:未关联仓库", severity="error")
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(f"已取消收藏:{ident}", severity="information")
self.app.notify(_("Unfavorited: {ident}").format(ident=ident), severity="information")
else:
favorite_manager.add(repo_path, ident)
self.app.notify(f"已收藏:{ident}", severity="information")
# 更新显示 (如果需要)
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("功能在记忆界面中不可用, 完成或返回后再试", severity="error")
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
@@ -286,4 +286,4 @@ class MemScreen(Screen):
a = Attic("ana")
l = a.data["last"]
a.data["last"] = time.time()
self.app.notify(f"时间恢复已修正: {l} -> {a.data['last']}")
self.app.notify(_("Time resume corrected: {old} -> {new}").format(old=l, new=a.data['last']))
+17 -19
View File
@@ -4,6 +4,7 @@ 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
@@ -12,36 +13,33 @@ logger = get_logger(__name__)
class NavigatorScreen(ModalScreen):
"""导航器模态窗口"""
"""Navigator modal screen"""
BINDINGS = [
("q", "go_back", "返回"),
("escape", "go_back", "返回"),
("n", "go_back", "切换"),
("q", "go_back", _("Back")),
("escape", "go_back", _("Back")),
("n", "go_back", _("Switch")),
]
SCREENS = [
("仪表盘", "dashboard"),
# ("创建仓库", "repo_creator"),
("缓存管理器", "precache_all"),
("收藏夹", FavoriteManagerScreen),
("设置页面", "setting"),
# ("调试日志", "logviewer"),
("同步工具", "synctool"),
("关于此软件", "about"),
# ("仓库编辑器", "repo_editor"),
(_("Dashboard"), "dashboard"),
(_("Cache Manager"), "precache_all"),
(_("Favorites"), FavoriteManagerScreen),
(_("Settings Page"), "setting"),
(_("Sync Tool"), "synctool"),
(_("About"), "about"),
]
OTHERS = [
("退出程序", "self.app.exit()"),
("项目主页", "webbrowser.open('https://ams.pluv27.top')"),
(_("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]请选择要跳转的功能\n或记忆会话实例[/b]\n\n将在此处显示提示",
_("[b]Select a function to navigate to\nor a memorization session instance[/b]\n\nTips will be displayed here"),
classes="title-label",
)
yield ListView(
@@ -49,9 +47,9 @@ class NavigatorScreen(ModalScreen):
id="nav-list",
classes="nav-list-view",
)
yield Static("按下回车以完成切换\n所有会话将被保存")
yield Static(_("Press Enter to switch\nAll sessions will be saved"))
yield Button(
"关闭 (n)",
_("Close (n)"),
id="close_button",
variant="primary",
classes="close-button",
+67 -59
View File
@@ -1,4 +1,4 @@
"""缓存工具界面"""
"""Cache tool screen"""
import pathlib
@@ -13,14 +13,15 @@ from textual import events, on
import heurams.kernel.particles as pt
import heurams.services.hasher as hasher
from heurams.context import *
from heurams.i18n import _
# 兼容性缓存路径:优先使用 paths.cache, 否则使用 data/cache
# 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}"
@@ -29,18 +30,18 @@ def human_size(bytes_num: int) -> str:
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 = "缓存管理器"
SUB_TITLE = _("Cache Manager")
BINDINGS = [
("q", "go_back", "返回"),
("q", "go_back", _("Back")),
]
def __init__(self, nucleons: list = [], desc: str = ""):
@@ -67,7 +68,7 @@ class PrecachingScreen(Screen):
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
@@ -78,7 +79,7 @@ class PrecachingScreen(Screen):
for repo in repos:
try:
total += len(repo.ident_index)
except (AttributeError, TypeError):
except:
continue
return total
@@ -89,7 +90,7 @@ class PrecachingScreen(Screen):
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
@@ -117,21 +118,25 @@ class PrecachingScreen(Screen):
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="precache_container"):
yield Label("[b]音频预缓存[/b]", classes="title-label")
yield Label(_("[b]Audio Pre-cache[/b]"), classes="title-label")
with Container():
yield Static(
f"缓存率: {self.cache_stats.get('cache_rate', 0):.1f}% (已缓存 {self.cache_stats.get('cached_units', 0)} / {self.cache_stats.get('total_units', 0)} 个单元)",
_("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(
f"目标单元归属: [b]{self.desc}[/b]", classes="target-info"
_("Target units from: [b]{desc}[/b]").format(desc=self.desc), classes="target-info"
)
yield Static(
f"单元数量: {len(self.nucleons)}", classes="target-info"
_("Unit count: {n}").format(n=len(self.nucleons)), classes="target-info"
)
else:
yield Static("目标: 所有单元", classes="target-info")
yield Static(_("Target: all units"), classes="target-info")
yield Static(id="status", classes="status-info")
yield Static(id="current_item", classes="current-item")
@@ -139,69 +144,72 @@ class PrecachingScreen(Screen):
with Horizontal(classes="button-group"):
if not self.is_precaching:
yield Button(
"开始预缓存", id="start_precache", variant="primary"
_("Start Pre-cache"), id="start_precache", variant="primary"
)
else:
yield Button(
"取消预缓存", id="cancel_precache", variant="error"
_("Cancel Pre-cache"), id="cancel_precache", variant="error"
)
yield Button("清空缓存", id="clear_cache", variant="warning")
yield Button("返回", id="go_back", variant="default")
yield Button(_("Clear Cache"), id="clear_cache", variant="warning")
yield Button(_("Back"), id="go_back", variant="default")
with Container(classes="cache-info"):
yield Static(f"缓存路径: {cache_dir}", classes="cache-path")
yield Static(_("Cache path: {path}").format(path=cache_dir), classes="cache-path")
yield Static(
f"文件数: {self.cache_stats['file_count']}", classes="cache-count"
_("Files: {n}").format(n=self.cache_stats['file_count']), classes="cache-count"
)
yield Static(
f"总大小: {self.cache_stats['human_size']}", classes="cache-size"
_("Total size: {size}").format(size=self.cache_stats['human_size']), classes="cache-size"
)
yield Button(
"刷新", id="refresh_cache_stats", variant="default", flat=True
_("Refresh"), id="refresh_cache_stats", variant="default", flat=True
)
yield Static("若您离开此界面, 未完成的缓存进程会自动停止.")
yield Static('缓存程序支持 "断点续传".')
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(f"文件数: {self.cache_stats['file_count']}")
cache_count_widget.update(_("Files: {n}").format(n=self.cache_stats['file_count']))
if cache_size_widget:
cache_size_widget.update(f"总大小: {self.cache_stats['human_size']}")
cache_size_widget.update(_("Total size: {size}").format(size=self.cache_stats['human_size']))
if cache_usage_text:
cache_usage_text.update(
f"缓存率: {self.cache_stats.get('cache_rate', 0):.1f}% "
f"(已缓存 {self.cache_stats.get('cached_units', 0)} / {self.cache_stats.get('total_units', 0)} 个单元)"
_("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):
"""预缓存单段文本的音频"""
"""Pre-cache audio for a single text string"""
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / f"{hasher.get_md5(text)}.wav"
@@ -212,21 +220,21 @@ class PrecachingScreen(Screen):
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 缓存"""
"""Cache based on Nucleon"""
ret = self.precache_by_text(nucleon["tts_text"])
return ret
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["tts_text"]
# self.current_item = text[:30] + "..." if len(text) > 50 else text
@@ -236,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
@@ -278,7 +286,7 @@ class PrecachingScreen(Screen):
repo.nucleonic_data_lict.get_itemic_unit(i)
)
)
except (KeyError, TypeError, AttributeError):
except:
continue
self.total = len(nucleon_list)
return self.precache_by_list(nucleon_list)
@@ -286,7 +294,7 @@ class PrecachingScreen(Screen):
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,
@@ -303,32 +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
shutil.rmtree(cache_dir, ignore_errors=True)
self.update_status("已清空", "音频缓存已清空", 0)
self._update_cache_display() # 更新缓存统计显示
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("缓存信息已刷新", severity="information")
self.app.notify(_("Cache info refreshed"), severity="information")
elif event.button.id == "go_back":
self.action_go_back()
+23 -20
View File
@@ -1,4 +1,4 @@
"""记忆准备界面"""
"""Memorization preparation screen"""
from textual.app import ComposeResult
from textual.containers import ScrollableContainer, Horizontal
@@ -19,6 +19,7 @@ from textual import events, on
import heurams.kernel.particles as pt
from heurams.context import *
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
@@ -28,11 +29,11 @@ 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')", ""),
]
@@ -61,29 +62,40 @@ class PreparationScreen(Screen):
)
with ScrollableContainer(id="main_container"):
yield Markdown(
f"**准备就绪**: `{self.repo.manifest['title']}`\n", id="title"
_("**Ready**: `{title}`\n").format(title=self.repo.manifest['title']), id="title"
)
yield Label(f"单元集路径: {self.repo.source}")
yield Label(_("Repo path: {path}").format(path=self.repo.source))
yield Label(
f"学习完成度: {self.repo.progress['touched']}/{len(self.repo)} [d]\\[{round(self.repo.progress['touched']/self.repo.progress['total']*100, 1)}%][/d]"
_("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"调度算法: {self.repo.config["algorithm"]} {algorithms[self.repo.config["algorithm"]].desc}"
_("Scheduling algorithm: {algo} {desc}").format(
algo=self.repo.config["algorithm"],
desc=algorithms[self.repo.config["algorithm"]].desc
)
)
yield Label(
f"学习数量: {self.repo.preview['review'] + self.scheduled_num} = {self.repo.preview['review']} [d][复习][/d] + {self.scheduled_num} [d][新识记][/d]\n",
_("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 Horizontal(
Button(
"开始记忆",
_("Start Memorizing"),
id="start_memorizing_button",
variant="primary",
classes="btn",
),
Button(
"管理缓存",
_("Manage Cache"),
id="precache_button",
variant="success",
classes="btn",
@@ -99,14 +111,6 @@ class PreparationScreen(Screen):
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 load_data(self):
self.scheduled_num = self.repo.config["scheduled_num"]
content = ""
@@ -154,7 +158,6 @@ class PreparationScreen(Screen):
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
logger.debug("按下按钮")
if event.button.id == "start_memorizing_button":
launch(repo=self.repo, app=self.app, scheduled_num=self.scheduled_num)
+14 -13
View File
@@ -1,4 +1,4 @@
"""设置页面"""
"""Settings screen"""
from textual.app import ComposeResult
from textual.containers import ScrollableContainer, Horizontal
@@ -16,6 +16,7 @@ from textual.widgets import (
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
@@ -26,12 +27,12 @@ logger = get_logger(__name__)
class SettingScreen(Screen):
"""设置页面屏幕"""
"""Settings screen"""
SUB_TITLE = "设置"
SUB_TITLE = _("Settings")
BINDINGS = [
("q", "go_back", "返回"),
("s", "go_back", "设置"),
("q", "go_back", _("Back")),
("s", "go_back", _("Settings")),
]
CSS_PATH = rootdir / "interface" / "css" / "screens" / "setting.tcss"
@@ -50,13 +51,13 @@ class SettingScreen(Screen):
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]设置页面[/b]")
yield Label("[b]" + _("Settings") + "[/b]")
for i in config_var.get():
if i.startswith("_"):
continue
@@ -67,7 +68,7 @@ class SettingScreen(Screen):
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()
@@ -138,7 +139,7 @@ class SettingScreen(Screen):
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Input(
value=str(parent[i]),
placeholder="要求一个浮点数",
placeholder=_("Requires a float"),
type="number",
id=domize(f"{parent_epath}.{i}"),
),
@@ -151,7 +152,7 @@ class SettingScreen(Screen):
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Input(
value=parent[i],
placeholder="要求一个字符串",
placeholder=_("Requires a string"),
type="text",
id=domize(f"{parent_epath}.{i}"),
),
@@ -176,7 +177,7 @@ class SettingScreen(Screen):
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Input(
value=str(parent[i]),
placeholder="要求一个整数",
placeholder=_("Requires an integer"),
type="integer",
id=domize(f"{parent_epath}.{i}"),
),
@@ -186,9 +187,9 @@ class SettingScreen(Screen):
elif isinstance(parent[i], list):
pass
else:
lst.append(Label("未知类型"))
lst.append(Label(_("Unknown type")))
return lst
return [Label("无子项")]
return [Label(_("No sub-items"))]
def on_mount(self) -> None:
"""挂载组件时初始化"""
+93 -83
View File
@@ -1,4 +1,4 @@
"""同步工具界面"""
"""Sync tool screen"""
import pathlib
import time
@@ -12,11 +12,12 @@ 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)
@@ -39,67 +40,67 @@ class SyncScreen(Screen):
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="sync_container"):
# 标题和连接状态
yield Static("同步工具", classes="title")
# Title and connection status
yield Static(_("Sync Tool"), classes="title")
yield Static("", id="status_label", classes="status")
# 配置信息
yield Static(f"同步协议: {config_var.get()['services']['sync']}")
yield Static("服务器配置:", classes="section_title")
# 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("远程服务器:", classes="config_label")
yield Static(_("Remote server:"), classes="config_label")
yield Static("", id="server_url", classes="config_value")
with Horizontal(classes="config_info"):
yield Static("远程路径:", classes="config_label")
yield Static(_("Remote path:"), classes="config_label")
yield Static("", id="remote_path", classes="config_value")
with Horizontal(classes="control_buttons"):
yield Button("测试连接", id="test_connection", variant="primary")
yield Button("开始同步", id="start_sync", variant="success")
yield Button("暂停", id="pause_sync", variant="warning", disabled=True)
yield Button("取消", id="cancel_sync", variant="error", disabled=True)
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("同步进度", classes="section_title")
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("同步日志", classes="section_title")
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("同步工具已启动")
self.log_message(_("Sync tool started"))
def update_ui_from_config(self):
"""更新 UI 显示配置信息"""
"""Update UI with config info"""
try:
sync_cfg: dict = config_var.get()["providers"]["sync"]["webdav"]
# 更新服务器 URL
url = sync_cfg.get("url", "未配置")
# 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("同步服务已就绪") # type: ignore
status_widget.update(_("Sync service ready")) # type: ignore
status_widget.add_class("ready")
else:
status_widget.update("同步服务未配置或未启用") # type: ignore
status_widget.update(_("Sync service not configured or not enabled")) # type: ignore
status_widget.add_class("error")
except Exception as e:
self.log_message(f"更新 UI 失败: {e}", is_error=True)
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
@@ -112,28 +113,28 @@ class SyncScreen(Screen):
progress_label.update(f"{progress}% - {current_item}" if current_item else f"{progress}%") # type: ignore
except Exception as e:
self.log_message(f"更新状态失败: {e}", is_error=True)
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 # 如果组件未就绪, 忽略错误
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":
@@ -148,124 +149,133 @@ class SyncScreen(Screen):
event.stop()
def test_connection(self):
"""测试 WebDAV 服务器连接"""
"""Test WebDAV server connection"""
if not self.sync_service:
self.log_message("同步服务未初始化, 请检查配置", is_error=True)
self.update_status("同步服务未初始化")
self.log_message(_("Sync service not initialised, please check configuration"), is_error=True)
self.update_status(_("Sync service not initialised"))
return
self.log_message("正在测试 WebDAV 连接...")
self.update_status("正在测试连接...")
self.log_message(_("Testing WebDAV connection..."))
self.update_status(_("Testing connection..."))
try:
success = self.sync_service.test_connection()
if success:
self.log_message("连接测试成功")
self.update_status("连接正常")
self.log_message(_("Connection test successful"))
self.update_status(_("Connection OK"))
else:
self.log_message("连接测试失败", is_error=True)
self.update_status("连接失败")
self.log_message(_("Connection test failed"), is_error=True)
self.update_status(_("Connection failed"))
except Exception as e:
self.log_message(f"连接测试异常: {e}", is_error=True)
self.update_status("连接异常")
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("同步服务未初始化, 无法开始同步", is_error=True)
self.log_message(_("Sync service not initialised, cannot start sync"), is_error=True)
return
if self.is_syncing:
self.log_message("同步已在进行中", is_error=True)
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("开始同步数据...")
self.update_status("正在同步...", progress=0)
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", {})
# 同步 nucleon 目录
# Sync nucleon directory
nucleon_dir = pathlib.Path(paths.get("nucleon_dir", "./data/nucleon"))
if nucleon_dir.exists():
self.log_message(f"同步 nucleon 目录: {nucleon_dir}")
self.update_status(f"同步 nucleon 目录...", progress=10)
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(
f"nucleon 同步完成: 上传 {result.get('uploaded', 0)} 个, 下载 {result.get('downloaded', 0)}"
_("nucleon sync complete: uploaded {up}, downloaded {down}").format(
up=result.get('uploaded', 0),
down=result.get('downloaded', 0),
)
)
else:
self.log_message(
f"nucleon 同步失败: {result.get('error', '未知错误')}",
_("nucleon sync failed: {err}").format(err=result.get('error', _('Unknown error'))),
is_error=True,
)
# 同步 electron 目录
# Sync electron directory
electron_dir = pathlib.Path(paths.get("electron_dir", "./data/electron"))
if electron_dir.exists():
self.log_message(f"同步 electron 目录: {electron_dir}")
self.update_status(f"同步 electron 目录...", progress=60)
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(
f"electron 同步完成: 上传 {result.get('uploaded', 0)} 个, 下载 {result.get('downloaded', 0)}"
_("electron sync complete: uploaded {up}, downloaded {down}").format(
up=result.get('uploaded', 0),
down=result.get('downloaded', 0),
)
)
else:
self.log_message(
f"electron 同步失败: {result.get('error', '未知错误')}",
_("electron sync failed: {err}").format(err=result.get('error', _('Unknown error'))),
is_error=True,
)
# 同步 orbital 目录 (如果存在)
# Sync orbital directory (if exists)
orbital_dir = pathlib.Path(paths.get("orbital_dir", "./data/orbital"))
if orbital_dir.exists():
self.log_message(f"同步 orbital 目录: {orbital_dir}")
self.update_status(f"同步 orbital 目录...", progress=80)
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(
f"orbital 同步完成: 上传 {result.get('uploaded', 0)} 个, 下载 {result.get('downloaded', 0)}"
_("orbital sync complete: uploaded {up}, downloaded {down}").format(
up=result.get('uploaded', 0),
down=result.get('downloaded', 0),
)
)
else:
self.log_message(
f"orbital 同步失败: {result.get('error', '未知错误')}",
_("orbital sync failed: {err}").format(err=result.get('error', _('Unknown error'))),
is_error=True,
)
# 同步完成
self.update_status("同步完成", progress=100)
self.log_message("所有目录同步完成")
# Sync complete
self.update_status(_("Sync complete"), progress=100)
self.log_message(_("All directories synced"))
except Exception as e:
self.log_message(f"同步过程中发生错误: {e}", is_error=True)
self.update_status("同步失败")
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
@@ -273,14 +283,14 @@ class SyncScreen(Screen):
self.update_button_states()
if self.is_paused:
self.log_message("同步已暂停")
self.update_status("同步已暂停")
self.log_message(_("Sync paused"))
self.update_status(_("Sync paused"))
else:
self.log_message("同步已恢复")
self.update_status("正在同步...")
self.log_message(_("Sync resumed"))
self.update_status(_("Syncing..."))
def cancel_sync(self):
"""取消同步"""
"""Cancel sync"""
if not self.is_syncing:
return
@@ -288,11 +298,11 @@ class SyncScreen(Screen):
self.is_paused = False
self.update_button_states()
self.log_message("同步已取消")
self.update_status("同步已取消")
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")
@@ -302,14 +312,14 @@ class SyncScreen(Screen):
start_button.disabled = True
pause_button.disabled = False
cancel_button.disabled = False
pause_button.label = "继续" if self.is_paused else "暂停" # type: ignore
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(f"更新按钮状态失败: {e}", is_error=True)
self.log_message(_("Failed to update button state: {error}").format(error=e), is_error=True)
def action_go_back(self):
self.app.pop_screen()
+20 -19
View File
@@ -5,6 +5,7 @@ from textual.widgets import Button, Label, Static
import heurams.kernel.particles as pt
from heurams.i18n import _
from .base_puzzle_widget import BasePuzzleWidget
@@ -32,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"])
@@ -10,6 +10,7 @@ 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
@@ -79,7 +80,6 @@ class ClozePuzzle(BasePuzzleWidget):
c += 1
self.hashmap[h] = i
btnid = f"sel000-{h}"
logger.debug(f"建立按钮 {btnid}")
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):
@@ -89,7 +89,7 @@ class ClozePuzzle(BasePuzzleWidget):
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"
+5 -3
View File
@@ -1,6 +1,8 @@
from textual.widget import Widget
from textual.widgets import Button, Label
from heurams.i18n import _
class Finished(Widget):
def __init__(
@@ -26,9 +28,9 @@ class Finished(Widget):
)
def compose(self):
yield Label("本次记忆进程结束", id="finished_msg")
yield Label(f"算法数据{'已保存' if self.is_saved else "未能保存"}")
yield Button("返回上一级", flat=True, 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
+7 -15
View File
@@ -1,4 +1,4 @@
# 单项选择题
# Multiple-choice puzzle
from typing import TypedDict
from textual.containers import ScrollableContainer
@@ -8,6 +8,7 @@ from textual.widgets import Button, Label
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 textual.events import Key
from .base_puzzle_widget import BasePuzzleWidget
@@ -65,15 +66,10 @@ class MCQPuzzle(BasePuzzleWidget):
def compose(self):
setting: Setting = self.atom.registry["nucleon"]["puzzles"][self.alia]
if len(self.inputlist) > len(self.puzzle.options):
logger.debug("ERR IDX")
logger.debug(self.inputlist)
logger.debug(self.puzzle.options)
else:
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")
# 渲染当前问题的选项
c = 0
@@ -85,21 +81,19 @@ class MCQPuzzle(BasePuzzleWidget):
h = str(hash(i))
self.hashmap[h] = i
btnid = f"sel{str(self.cursor).zfill(3)}-{h}"
logger.debug(f"建立按钮 {btnid}")
self.btn_shortcuts[f"{c}"] = f"{btnid}"
yield Button(f"[{c}] " + i, id=f"{btnid}")
s.focus()
yield Button("退格", id="delete")
yield Button(_("Backspace"), 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)
@@ -122,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
@@ -143,7 +137,6 @@ class MCQPuzzle(BasePuzzleWidget):
def refresh_buttons(self):
"""刷新按钮显示(用于题目切换)"""
# 移除所有选项按钮
logger.debug("刷新按钮")
self.cursor += 1
container = self.query_one("#btn-container")
buttons_to_remove = [
@@ -153,7 +146,6 @@ class MCQPuzzle(BasePuzzleWidget):
]
container.focus()
for button in buttons_to_remove:
logger.info(button)
container.remove_children("#" + button.id) # type: ignore
# 添加当前题目的选项按钮
+4 -2
View File
@@ -1,6 +1,8 @@
from textual.widget import Widget
from textual.widgets import Button, Label
from heurams.i18n import _
class Placeholder(Widget):
def __init__(
@@ -23,8 +25,8 @@ class Placeholder(Widget):
)
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
+3 -2
View File
@@ -6,6 +6,7 @@ from textual.widget import Widget
from textual.widgets import Button, Label, Markdown, Static
import heurams.kernel.particles as pt
from heurams.i18n import _
from heurams.services.logger import get_logger
from .base_puzzle_widget import BasePuzzleWidget
@@ -86,7 +87,7 @@ class Recognition(BasePuzzleWidget):
for item in cfg["secondary"]:
if isinstance(item, list):
for j in item:
yield Markdown(f"### 笔记: {j}") # TODO ANNOTATION
yield Markdown(_("### Note: {note}").format(note=j)) # TODO ANNOTATION
continue
if isinstance(item, Dict):
total = ""
@@ -97,7 +98,7 @@ class Recognition(BasePuzzleWidget):
yield Markdown(item)
with Center() as c:
with Button("我已知晓", id="ok") as b:
with Button(_("I know this"), id="ok") as b:
b.focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
@@ -1,8 +1,3 @@
"""算法模块
自动发现并加载所有算法实现, 提供统一的算法注册表.
"""
import importlib
import pkgutil
from pathlib import Path
+5 -75
View File
@@ -9,17 +9,6 @@ _registry: dict[str, type["BaseAlgorithm"]] = {}
class BaseAlgorithm:
"""间隔重复算法基类
定义所有调度算法必须实现的接口. 子类通过继承此类并设置 algo_name
自动注册到全局算法注册表.
Attributes:
algo_name: 算法的唯一标识名称, 用于注册和查找
desc: 算法的简短描述
defaults: 算法数据字典的默认值模板
"""
algo_name = "BaseAlgorithm"
desc = "算法基类"
@@ -29,11 +18,6 @@ class BaseAlgorithm:
@classmethod
def get_registry(cls) -> dict[str, type["BaseAlgorithm"]]:
"""获取所有已注册算法的字典
Returns:
键为 algo_name, 值为算法类的字典
"""
return dict(_registry)
class AlgodataDict(TypedDict):
@@ -59,82 +43,28 @@ class BaseAlgorithm:
def revisor(
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
) -> None:
"""迭代记忆数据
根据用户反馈更新算法状态, 计算下一次复习时间.
Args:
algodata: 算法数据字典, 包含该算法的所有状态参数
feedback: 用户反馈评分 (0-5), -1 表示跳过更新
is_new_activation: 是否为首次激活, 首次激活时重置部分参数
"""
logger.debug(
"BaseAlgorithm.revisor 被调用, algodata keys: %s, feedback: %d, is_new_activation: %s",
list(algodata.keys()) if algodata else [],
feedback,
is_new_activation,
)
"""迭代记忆数据"""
@classmethod
def is_due(cls, algodata) -> int:
"""判断是否应该复习
Args:
algodata: 算法数据字典
Returns:
1 表示应该复习, 0 表示不需要
"""
logger.debug(
"BaseAlgorithm.is_due 被调用, algodata keys: %s",
list(algodata.keys()) if algodata else [],
)
"""是否应该复习"""
return 1
@classmethod
def get_rating(cls, algodata) -> str:
"""获取当前记忆状态的评分信息
"""获取评分信息"""
Args:
algodata: 算法数据字典
Returns:
评分的字符串表示, 如 efactor 值
"""
logger.debug(
"BaseAlgorithm.rate 被调用, algodata keys: %s",
list(algodata.keys()) if algodata else [],
)
return ""
@classmethod
def nextdate(cls, algodata) -> int:
"""获取下一次复习的时间戳
Args:
algodata: 算法数据字典
Returns:
下次复习的日期戳 (天数), -1 表示无计划
"""
logger.debug(
"BaseAlgorithm.nextdate 被调用, algodata keys: %s",
list(algodata.keys()) if algodata else [],
)
"""获取下一次记忆时间戳"""
return -1
@classmethod
def check_integrity(cls, algodata):
"""校验算法数据完整性
Args:
algodata: 算法数据字典
Returns:
1 表示数据完整, 0 表示数据缺失或格式错误
"""
try:
cls.AlgodataDict(**algodata[cls.algo_name])
return 1
except (KeyError, TypeError, ValueError):
except:
return 0
+12 -17
View File
@@ -33,7 +33,7 @@ def _get_global_scheduler():
with open(_SCHEDULER_STATE_FILE, "r", encoding="utf-8") as f:
return Scheduler.from_json(f.read())
except Exception:
logger.warning("FSRS Scheduler 状态文件加载失败, 创建新实例")
logger.warning("No former FSRS Scheduler file founded, creating new instance")
return Scheduler()
@@ -45,7 +45,7 @@ def _save_global_scheduler(scheduler):
with open(_SCHEDULER_STATE_FILE, "w", encoding="utf-8") as f:
f.write(data)
except Exception:
logger.exception("FSRS Scheduler 状态保存失败")
logger.error("Failed to persist FSRS Scheduler state")
def _feedback_to_rating(feedback: int) -> Rating:
@@ -61,7 +61,7 @@ def _feedback_to_rating(feedback: int) -> Rating:
def _datetime_to_daystamp(dt: datetime) -> int:
"""将 datetime 转换为天数戳 (从 1970-01-01) """
"""将 datetime 转换为天数戳从 1970-01-01"""
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
delta = dt - epoch
return delta.days
@@ -81,7 +81,7 @@ class FSRSAlgorithm(BaseAlgorithm):
# FSRS 特有字段
fsrs_state: int # State 枚举值: 1=Learning, 2=Review, 3=Relearning
fsrs_step: int # 当前学习步进索引, -1 表示 None (Review 状态)
fsrs_stability: float # 稳定性 (秒) , 0.0 表示尚未计算
fsrs_stability: float # 稳定性(秒), 0.0 表示尚未计算
fsrs_difficulty: float # 难度 [1.0, 10.0], 0.0 表示尚未计算
# 标准 BaseAlgorithm 兼容字段
real_rept: int
@@ -116,11 +116,11 @@ class FSRSAlgorithm(BaseAlgorithm):
# State: int → IntEnum
card.state = data.get("fsrs_state", 1)
# Step: -1 表示 None (Review 状态下的 card.step 为 None)
# Step: -1 表示 NoneReview 状态下的 card.step 为 None
step = data.get("fsrs_step", -1)
card.step = None if step == -1 else step
# Stability: 0.0 表示尚未计算 (新卡片)
# Stability: 0.0 表示尚未计算新卡片
stability = data.get("fsrs_stability", 0.0)
card.stability = None if stability == 0.0 else stability
@@ -170,20 +170,15 @@ class FSRSAlgorithm(BaseAlgorithm):
):
"""FSRS 算法迭代决策机制实现
将 feedback (0-5) 映射为 FSRS Rating 后交由 py-fsrs 调度器处理.
将 feedback (0-5) 映射为 FSRS Rating 后交由 py-fsrs 调度器处理
Args:
feedback (int): 0-5 的记忆保留率量化参数
is_new_activation: 是否为全新激活 (重置为初始状态)
is_new_activation: 是否为全新激活重置为初始状态
"""
logger.debug(
"FSRS.revisor 开始, feedback: %d, is_new_activation: %s",
feedback,
is_new_activation,
)
if feedback == -1:
logger.debug("feedback -1, 跳过更新")
logger.debug("feedback = -1, update skipped")
return
scheduler = _get_global_scheduler()
@@ -191,7 +186,7 @@ class FSRSAlgorithm(BaseAlgorithm):
if is_new_activation:
card = Card()
logger.debug("新激活, 创建新 Card")
logger.debug("New activation, create new Card")
else:
card = cls._algodata_to_card(algodata)
@@ -201,12 +196,12 @@ class FSRSAlgorithm(BaseAlgorithm):
cls._card_to_algodata(card, algodata)
# real_rept: 总复习次数
algodata[cls.algo_name]["real_rept"] += 1
# rept: 成功回忆次数 (feedback ≥ 3 视为成功)
# rept: 成功回忆次数feedback ≥ 3 视为成功
if feedback >= 3:
algodata[cls.algo_name]["rept"] += 1
logger.debug(
"FSRS.revisor 完成: stability=%s, difficulty=%s, state=%s, " "next_date=%d",
"FSRS.revisor finished: stability=%s, difficulty=%s, state=%s, " "next_date=%d",
card.stability,
card.difficulty,
card.state,
+8 -44
View File
@@ -9,16 +9,6 @@ logger = get_logger(__name__)
class NSP0Algorithm(BaseAlgorithm):
"""NSP-0 非间隔重复调度器
快速筛选用算法, 对低分项目保持每日复习, 高分项目标记为已掌握.
适用于需要快速过滤大量材料的场景.
Attributes:
algo_name: "NSP-0"
desc: 快速筛选用非间隔重复调度器
"""
algo_name = "NSP-0"
desc = "快速筛选用非间隔重复调度器"
@@ -48,13 +38,11 @@ class NSP0Algorithm(BaseAlgorithm):
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
):
"""NSP-0 算法迭代决策机制实现
低分 (feedback<=3) 设置间隔为 1 天, 高分标记为已掌握 (间隔无限).
根据 quality(0 ~ 5) 进行参数迭代最佳间隔
quality 由主程序评估
Args:
algodata: 算法数据字典
feedback: 记忆保留率量化参数 (0-5), -1 表示跳过
is_new_activation: 是否为首次激活
quality (int): 记忆保留率量化参数
"""
logger.debug(
"NSP0.revisor 开始, feedback: %d, is_new_activation: %s",
@@ -63,7 +51,7 @@ class NSP0Algorithm(BaseAlgorithm):
)
if feedback == -1:
logger.debug("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"]:
@@ -77,7 +65,7 @@ class NSP0Algorithm(BaseAlgorithm):
algodata[cls.algo_name]["last_modify"] = timer.get_timestamp()
logger.debug(
"更新日期: last_date=%d, next_date=%d, last_modify=%f",
"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"],
@@ -85,14 +73,6 @@ class NSP0Algorithm(BaseAlgorithm):
@classmethod
def is_due(cls, algodata):
"""判断是否应该复习
Args:
algodata: 算法数据字典
Returns:
True 表示到期, False 表示未到期
"""
result = algodata[cls.algo_name]["next_date"] <= timer.get_daystamp()
logger.debug(
"NSP0.is_due: next_date=%d, current_daystamp=%d, result=%s",
@@ -104,28 +84,12 @@ class NSP0Algorithm(BaseAlgorithm):
@classmethod
def get_rating(cls, algodata):
"""获取当前 important 标记作为评分信息
Args:
algodata: 算法数据字典
Returns:
important 值的字符串表示
"""
important = algodata[cls.algo_name]["important"]
logger.debug("NSP0.rate: important=%d", important)
return str(important)
efactor = algodata[cls.algo_name]["efactor"]
logger.debug("NSP0.rate: efactor=%f", efactor)
return str(efactor)
@classmethod
def nextdate(cls, algodata) -> int:
"""获取下一次复习日期
Args:
algodata: 算法数据字典
Returns:
下次复习的天数戳
"""
next_date = algodata[cls.algo_name]["next_date"]
logger.debug("NSP0.nextdate: %d", next_date)
return next_date
+7 -7
View File
@@ -614,7 +614,7 @@ def _get_global_sm():
with open(_GLOBAL_STATE_FILE, "r", encoding="utf-8") as f:
return SM.load(json.load(f))
except Exception:
logger.warning("SM-15M 全局状态文件加载失败, 创建新实例")
logger.warning("Failed to load SM-15M global state file, creating new instance")
sm = SM()
_save_global_sm(sm)
return sm
@@ -626,7 +626,7 @@ def _save_global_sm(sm):
with open(_GLOBAL_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(sm.data(), f, indent=2)
except Exception:
logger.exception("SM-15M 全局状态保存失败")
logger.error("Failed to save SM-15M global state")
# ============================================================================
@@ -643,13 +643,13 @@ class SM15MAlgorithm(BaseAlgorithm):
lapse: int
repetition: int
of_val: float # O-Factor
optimum_interval_days: int # 最优间隔 (天)
optimum_interval_days: int # 最优间隔(天)
afs: list # A-Factor 历史
af: float # 当前 A-Factor
# 毫秒精度 (子日排程)
# 毫秒精度子日排程
last_date_ms: int
next_date_ms: int
# BaseAlgorithm 兼容 (天精度, 向后兼容)
# BaseAlgorithm 兼容天精度, 向后兼容
real_rept: int
rept: int
interval: int
@@ -758,7 +758,7 @@ class SM15MAlgorithm(BaseAlgorithm):
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
):
logger.debug(
"SM-15M.revisor 开始, feedback=%d, is_new_activation=%s",
"SM-15M.revisor, feedback=%d, is_new_activation=%s",
feedback,
is_new_activation,
)
@@ -788,7 +788,7 @@ class SM15MAlgorithm(BaseAlgorithm):
algodata[cls.algo_name]["rept"] += 1
logger.debug(
"SM-15M.revisor 完成: repetition=%d, of=%.4f, next_date=%d",
"SM-15M.revisor: repetition=%d, of=%.4f, next_date=%d",
item.repetition,
item.of,
algodata[cls.algo_name]["next_date"],
+13 -49
View File
@@ -9,16 +9,6 @@ logger = get_logger(__name__)
class SM2Algorithm(BaseAlgorithm):
"""SuperMemo-2 算法实现
经典间隔重复算法, 基于 1987 年 Piotr Wozniak 设计的 SM-2.
通过维护 efactor (难度因子) 来调整复习间隔.
Attributes:
algo_name: "SM-2"
desc: SuperMemo2 (1987) 简单间隔重复调度器
"""
algo_name = "SM-2"
desc = "SuperMemo2 (1987) 简单间隔重复调度器"
@@ -48,22 +38,20 @@ class SM2Algorithm(BaseAlgorithm):
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
):
"""SM-2 算法迭代决策机制实现
根据 feedback (0-5) 更新 efactor 并计算下次复习间隔.
根据 quality(0 ~ 5) 进行参数迭代最佳间隔
quality 由主程序评估
Args:
algodata: 算法数据字典
feedback: 记忆保留率量化参数 (0-5), -1 表示跳过
is_new_activation: 是否为首次激活
quality (int): 记忆保留率量化参数
"""
logger.debug(
"SM2.revisor 开始, feedback: %d, is_new_activation: %s",
"SM2.revisor, feedback: %d, is_new_activation: %s",
feedback,
is_new_activation,
)
if feedback == -1:
logger.debug("feedback -1, 跳过更新")
logger.debug("feedback = -1, update skipped")
return
algodata[cls.algo_name]["efactor"] = algodata[cls.algo_name]["efactor"] + (
@@ -72,7 +60,7 @@ class SM2Algorithm(BaseAlgorithm):
algodata[cls.algo_name]["efactor"] = max(
1.3, algodata[cls.algo_name]["efactor"]
)
logger.debug("更新 efactor: %f", algodata[cls.algo_name]["efactor"])
logger.debug("Update efactor: %f", algodata[cls.algo_name]["efactor"])
if feedback < 3:
algodata[cls.algo_name]["rept"] = 0
@@ -80,28 +68,28 @@ class SM2Algorithm(BaseAlgorithm):
logger.debug("feedback < 3, 重置 rept 和 interval")
else:
algodata[cls.algo_name]["rept"] += 1
logger.debug("递增 rept: %d", algodata[cls.algo_name]["rept"])
logger.debug("Increase rept: %d", algodata[cls.algo_name]["rept"])
algodata[cls.algo_name]["real_rept"] += 1
logger.debug("递增 real_rept: %d", algodata[cls.algo_name]["real_rept"])
logger.debug("Increase real_rept: %d", algodata[cls.algo_name]["real_rept"])
if is_new_activation:
algodata[cls.algo_name]["rept"] = 0
algodata[cls.algo_name]["efactor"] = 2.5
logger.debug("新激活, 重置 rept efactor")
logger.debug("New activation, reset rept and efactor")
if algodata[cls.algo_name]["rept"] == 0:
algodata[cls.algo_name]["interval"] = 1
logger.debug("rept=0, 设置 interval=1")
logger.debug("rept=0, set interval=1")
elif algodata[cls.algo_name]["rept"] == 1:
algodata[cls.algo_name]["interval"] = 6
logger.debug("rept=1, 设置 interval=6")
logger.debug("rept=1, set interval=6")
else:
algodata[cls.algo_name]["interval"] = round(
algodata[cls.algo_name]["interval"] * algodata[cls.algo_name]["efactor"]
)
logger.debug(
"rept>1, 计算 interval: %d", algodata[cls.algo_name]["interval"]
"rept>1, providing interval: %d", algodata[cls.algo_name]["interval"]
)
algodata[cls.algo_name]["last_date"] = timer.get_daystamp()
@@ -111,7 +99,7 @@ class SM2Algorithm(BaseAlgorithm):
algodata[cls.algo_name]["last_modify"] = timer.get_timestamp()
logger.debug(
"更新日期: last_date=%d, next_date=%d, last_modify=%f",
"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"],
@@ -119,14 +107,6 @@ class SM2Algorithm(BaseAlgorithm):
@classmethod
def is_due(cls, algodata):
"""判断是否应该复习
Args:
algodata: 算法数据字典
Returns:
True 表示到期, False 表示未到期
"""
result = algodata[cls.algo_name]["next_date"] <= timer.get_daystamp()
logger.debug(
"SM2.is_due: next_date=%d, current_daystamp=%d, result=%s",
@@ -138,28 +118,12 @@ class SM2Algorithm(BaseAlgorithm):
@classmethod
def get_rating(cls, algodata):
"""获取当前 efactor 作为评分信息
Args:
algodata: 算法数据字典
Returns:
efactor 值的字符串表示
"""
efactor = algodata[cls.algo_name]["efactor"]
logger.debug("SM2.rate: efactor=%f", efactor)
return str(efactor)
@classmethod
def nextdate(cls, algodata) -> int:
"""获取下一次复习日期
Args:
algodata: 算法数据字典
Returns:
下次复习的天数戳
"""
next_date = algodata[cls.algo_name]["next_date"]
logger.debug("SM2.nextdate: %d", next_date)
return next_date
+2 -32
View File
@@ -1,41 +1,19 @@
class Evalizer:
"""几乎无副作用的模板系统
接受环境信息并创建一个模板解析工具, 递归遍历数据结构,
"eval:" 前缀的字符串执行表达式求值.
副作用问题: 仅存在于 eval 函数调用.
Attributes:
env: 模板求值时的环境变量字典
接受环境信息并创建一个模板解析工具, 工具传入参数支持list, dict及其嵌套
副作用问题: 仅存在于 eval 函数
"""
# TODO: 弃用风险极高的 eval
# TODO: 异步/多线程执行避免堵塞
def __init__(self, environment: dict) -> None:
"""初始化模板解析器
Args:
environment: 模板求值时的环境变量字典
"""
self.env = environment
def __call__(self, anyobj):
"""调用入口, 等同于 travel"""
return self.travel(anyobj)
def travel(self, anyobj):
"""递归遍历数据结构并展开模板表达式
支持 list, dict, tuple 的嵌套结构.
字符串以 "eval:" 开头时执行表达式求值.
Args:
anyobj: 任意 Python 对象
Returns:
展开后的对象
"""
if isinstance(anyobj, list):
return list(map(self.travel, anyobj))
elif isinstance(anyobj, dict):
@@ -51,13 +29,5 @@ class Evalizer:
return anyobj
def eval_with_env(self, s: str):
"""在环境变量上下文中执行表达式
Args:
s: Python 表达式字符串
Returns:
表达式求值结果
"""
ret = eval(s, globals(), self.env)
return ret
+1 -46
View File
@@ -50,11 +50,6 @@ class Lict(MutableSequence):
self._list_dirty = False
def __getitem__(self, key):
"""按键或索引获取值
Args:
key: 字符串键 (字典访问) 或整数索引 (列表访问)
"""
if isinstance(key, str):
return self._dict[key]
else:
@@ -62,12 +57,7 @@ class Lict(MutableSequence):
return self._list[key]
def __setitem__(self, key, value):
"""按键或索引设置值
Args:
key: 字符串键 (字典设置) 或整数索引 (替换列表元组)
value: 新值, 索引访问时必须为 (key, value) 元组
"""
"""传入键值对时等同于操作字典, 传入索引+元组时等用于替换某索引的列表值为新元组"""
if isinstance(key, str):
self._dict[key] = value
self._list_dirty = True
@@ -91,17 +81,14 @@ class Lict(MutableSequence):
del self._dict[del_key]
def keys(self):
"""返回所有键"""
self._sync_if_needed()
return self._dict.keys()
def values(self):
"""返回所有值"""
self._sync_if_needed()
return self._dict.values()
def items(self):
"""返回所有键值对元组列表"""
self._sync_if_needed()
return self._list
@@ -118,14 +105,6 @@ class Lict(MutableSequence):
return item in self._list or item in self.keys() or item in self.values()
def append(self, item):
"""追加键值对元组
Args:
item: (key, value) 格式的元组
Raises:
NotImplementedError: item 不是二元组
"""
if item != (item[0], item[1]):
raise NotImplementedError
self._sync_if_needed() # 以防 forced_order
@@ -135,14 +114,6 @@ class Lict(MutableSequence):
self._sync_if_needed() # 以防 forced_order
def append_if_it_doesnt_exist_before(self, item: Any):
"""若键不存在则追加键值对
Args:
item: (key, value) 格式的元组
Raises:
NotImplementedError: item 不是二元组
"""
if item != (item[0], item[1]):
raise NotImplementedError
self._sync_if_needed()
@@ -191,25 +162,9 @@ class Lict(MutableSequence):
raise NotImplementedError
def get_itemic_unit(self, ident):
"""获取指定键的 (key, value) 元组
Args:
ident: 键名
Returns:
(key, value) 格式的元组
"""
return (ident, self._dict[ident])
def keys_equal_with(self, other):
"""比较两个 Lict 的键集合是否相同
Args:
other: 另一个 Lict 实例
Returns:
True 表示键集合相同
"""
self._sync_if_needed()
return self.key_equality(self, other)
-5
View File
@@ -1,8 +1,3 @@
"""粒子数据模型模块
定义记忆单元的核心数据结构: Nucleon (内容)、Electron (状态)、Atom (组装).
"""
from .atom import Atom
from .electron import Electron
from .nucleon import Nucleon
+3 -3
View File
@@ -63,7 +63,7 @@ class Atom:
)
def lock(self, locked=-1):
logger.debug(f"锁定参数 {locked}")
logger.debug(f"Lock atom: {locked}")
"""锁定, 效果等同于 self.registry['runtime']['locked'] = locked 或者返回是否锁定"""
if locked == 1:
self.registry["runtime"]["locked"] = True
@@ -80,13 +80,13 @@ class Atom:
PuzzleWidget 的 handler 除了测试, 严禁直接执行 Electron 的 revisor 函数, 否则造成逻辑混乱
"""
if self.registry["runtime"]["locked"]:
logger.debug(f"允许总评分: {self.registry['runtime']['min_rate']}")
logger.debug(f"Rating allowed: {self.registry['runtime']['min_rate']}")
self.registry["electron"].revisor(
self.registry["runtime"]["min_rate"],
is_new_activation=self.registry["runtime"]["new_activation"],
)
else:
logger.debug("禁止总评分")
logger.debug("Rating disallowed")
def __getitem__(self, key):
return self.registry[key]
+1 -2
View File
@@ -70,8 +70,7 @@ class Electron:
try:
result = self.algo.get_rating(self.algodata)
return result
except (KeyError, TypeError, AttributeError) as e:
logger.warning("获取评分失败 (ident=%s): %s", self.ident, e)
except:
return 0
def nextdate(self) -> int:
+2 -61
View File
@@ -8,27 +8,9 @@ logger = get_logger(__name__)
class Nucleon:
"""原子核: 带有运行时隔离的模板化只读材料元数据容器
封装记忆单元的内容数据, 通过 Evalizer 模板系统展开 payload 和 common
中的动态表达式. 创建后数据不可修改.
Attributes:
ident: 记忆单元的唯一标识
data: 展开后的内容字典 (只读)
"""
"""原子核: 带有运行时隔离的模板化只读材料元数据容器"""
def __init__(self, ident, payload, common):
"""初始化核子
合并 payload 和 common, 通过 Evalizer 展开模板表达式.
展开失败时静默降级为原始数据.
Args:
ident: 记忆单元标识
payload: 记忆内容字典
common: 通用元数据字典
"""
self.ident = ident
try:
data_safe = deepcopy((payload | common))
@@ -54,64 +36,31 @@ class Nucleon:
self.data = payload | common
def __getitem__(self, key):
"""按字符串键获取数据
Args:
key: 字符串键名, "ident" 返回标识
Returns:
对应键的值
Raises:
AttributeError: 键类型不是字符串
"""
if isinstance(key, str):
if key == "ident":
return self.ident
return self.data[key]
else:
raise AttributeError(f"Nucleon 仅支持字符串键访问, 收到: {type(key).__name__}")
raise AttributeError
def __setitem__(self, key, value):
"""禁止修改 (只读容器)
Raises:
AttributeError: 始终抛出
"""
raise AttributeError("应为只读")
def __delitem__(self, key):
"""禁止删除 (只读容器)
Raises:
AttributeError: 始终抛出
"""
raise AttributeError("应为只读")
def __iter__(self):
"""迭代数据字典的键"""
return iter(self.data)
def __contains__(self, key):
"""检查键是否存在于数据中"""
return key in (self.data)
def get(self, key, default=None):
"""安全获取数据值
Args:
key: 键名
default: 键不存在时的默认值
Returns:
键对应的值或默认值
"""
if key in self:
return self[key]
return default
def __len__(self):
"""返回数据字典的长度"""
return len(self.data)
def __repr__(self):
@@ -122,14 +71,6 @@ class Nucleon:
@staticmethod
def from_data(nucleonic_data: tuple):
"""从元组数据创建核子
Args:
nucleonic_data: 格式为 (ident, (payload, common)) 的元组
Returns:
Nucleon 实例
"""
_data = nucleonic_data
payload = _data[1][0]
common = _data[1][1]
@@ -1,8 +1,3 @@
"""占位符模块
提供用于 UI 预览和测试的占位粒子对象, 避免空值错误.
"""
from .atom import Atom
from .electron import Electron
from .nucleon import Nucleon
@@ -26,8 +21,6 @@ orbital_placeholder = {
class NucleonPlaceholder(Nucleon):
"""核子占位符, 用于 UI 预览"""
def __init__(self):
super().__init__("__placeholder__", {}, {})
@@ -36,15 +29,11 @@ class NucleonPlaceholder(Nucleon):
class ElectronPlaceholder(Electron):
"""电子占位符, 用于 UI 预览"""
def __init__(self):
super().__init__("__placeholder__", {"": {"": ""}}, "")
class AtomPlaceholder(Atom):
"""原子占位符, 用于 UI 预览"""
def __init__(self):
super().__init__(
NucleonPlaceholder(), ElectronPlaceholder(), orbital_placeholder
+2 -4
View File
@@ -8,9 +8,7 @@ class BasePuzzle:
"""谜题基类"""
def refresh(self):
logger.debug("BasePuzzle.refresh 被调用(未实现)")
raise NotImplementedError("谜题对象未实现 refresh 方法")
raise NotImplementedError("Method refresh not implemented")
def __str__(self):
logger.debug("BasePuzzle.__str__ 被调用")
return f"谜题: {type(self).__name__}"
return f"Puzzle: {type(self).__name__}"
+6 -7
View File
@@ -27,20 +27,20 @@ class ClozePuzzle(BasePuzzle):
self.wording = "填空题 - 尚未刷新谜题"
self.answer = ["填空题 - 尚未刷新谜题"]
self.delimiter = delimiter
logger.debug("ClozePuzzle 初始化完成")
logger.debug("ClozePuzzle inited")
def refresh(self): # 刷新谜题
logger.debug("ClozePuzzle.refresh 开始")
logger.debug("ClozePuzzle.refresh")
placeholder = "___SLASH___"
tmp_text = self.text.replace(self.delimiter, placeholder)
words = tmp_text.split(placeholder)
if not words:
logger.warning("ClozePuzzle.refresh: 无单词可处理")
logger.warning("ClozePuzzle.refresh: nothing to proceed")
return
words = [word for word in words if word]
logger.debug("ClozePuzzle.refresh: 分割出 %d 个单词", len(words))
logger.debug("ClozePuzzle.refresh: %d words splited", len(words))
num_blanks = min(max(1, len(words) // self.min_denominator), len(words))
logger.debug("ClozePuzzle.refresh: 需要生成 %d 个填空", num_blanks)
logger.debug("ClozePuzzle.refresh: %d blank required", num_blanks)
indices_to_blank = random.sample(range(len(words)), num_blanks)
indices_to_blank.sort()
blanked_words = list(words)
@@ -50,8 +50,7 @@ class ClozePuzzle(BasePuzzle):
answer.append(words[index])
self.answer = answer
self.wording = "".join(blanked_words)
logger.debug("ClozePuzzle.refresh 完成, 生成 %d 个填空", len(answer))
logger.debug("ClozePuzzle.refresh, %d blanks generated", len(answer))
def __str__(self):
logger.debug("ClozePuzzle.__str__ 被调用")
return f"{self.wording}\n{str(self.answer)}"
-7
View File
@@ -1,5 +1,3 @@
"""猜测谜题模块 (预留)"""
from heurams.services.logger import get_logger
from .base import BasePuzzle
@@ -8,10 +6,5 @@ logger = get_logger(__name__)
class GuessPuzzle(BasePuzzle):
"""猜测型谜题 (预留实现)
要求用户猜测词义, 尚未完成实现.
"""
def __init__(self):
super().__init__()
+3 -3
View File
@@ -65,8 +65,8 @@ class MCQPuzzle(BasePuzzle):
jammer: 传入的干扰项列表
"""
# 合并正确答案和传入的干扰项, 并去重
logger.debug(f"答案映射: {self.mapping}, {type(self.mapping)}")
logger.debug(f"干扰项: {jammer}, {type(jammer)}")
logger.debug(f"Answer table: {self.mapping}, {type(self.mapping)}")
logger.debug(f"Jammers: {jammer}, {type(jammer)}")
unique_jammers = set(jammer + list(self.mapping.values()))
self.jammer = list(unique_jammers)
@@ -94,7 +94,7 @@ class MCQPuzzle(BasePuzzle):
Raises:
ValueError: 当mapping为空时不会抛出异常, 但会设置空谜题状态
"""
logger.debug("MCQPuzzle.refresh 开始, mapping size=%d", len(self.mapping))
logger.debug("MCQPuzzle.refresh, mapping size=%d", len(self.mapping))
if not self.mapping:
self._set_empty_puzzle()
return
+3 -7
View File
@@ -1,4 +1,4 @@
"""识别谜题模块"""
# mcq.py
from heurams.services.logger import get_logger
@@ -8,14 +8,10 @@ logger = get_logger(__name__)
class RecognitionPuzzle(BasePuzzle):
"""识别型谜题
展示内容供用户识别确认, 无需主动回忆.
常用于复习流程的最后阶段 (retronly 回溯模式).
"""
"""识别占位符"""
def __init__(self) -> None:
super().__init__()
def refresh(self):
"""刷新谜题 (识别型无需生成内容)"""
pass
+1 -4
View File
@@ -1,7 +1,4 @@
"""反应器模块
基于三层嵌套状态机 (Router -> Procession -> Expander) 实现复习流程调度与排程.
"""
from heurams.services.logger import get_logger
from .expander import Expander
from .router import Router
+2 -39
View File
@@ -14,17 +14,7 @@ logger = get_logger(__name__)
class Expander(Machine):
"""单原子调度展开器
根据轨道策略 (orbital) 将单个原子展开为谜题序列.
包含 exammode (考试模式) retronly (回溯模式) 两个阶段.
Attributes:
atom: 关联的 Atom 实例
route: 当前路由阶段
puzzles_inf: 展开后的谜题信息列表
min_ratings: 每个谜题的最低评分记录
"""
"""单原子调度展开器"""
def __init__(self, atom: pt.Atom, route=RouterState.RECOGNITION):
self.route = route
@@ -58,7 +48,7 @@ class Expander(Machine):
self.puzzles_inf = list()
self.min_ratings = []
for item, possibility in orbital_schedule: # type: ignore
logger.debug(f"开始处理: {item}")
logger.debug(f"Process: {item}")
puzzle = puz.puzzles[orbital_puzzles[item]["__origin__"]]
@@ -95,47 +85,20 @@ class Expander(Machine):
)
def get_puzzles_inf(self):
"""获取谜题信息列表
回溯模式下返回识别谜题, 否则返回展开的谜题列表.
Returns:
谜题信息字典列表
"""
if self.state == "retronly":
return [{"puzzle": puz.puzzles["recognition"], "alia": "Recognition"}]
return self.puzzles_inf
def get_current_puzzle_inf(self):
"""获取当前谜题信息
Returns:
当前谜题的信息字典
"""
if self.state == "retronly":
return {"puzzle": puz.puzzles["recognition"], "alia": "Recognition"}
return self.current_puzzle_inf
def report(self, rating):
"""报告当前谜题的评分
Args:
rating: 用户评分 (0-5)
"""
if self.puzzles_inf:
self.min_ratings[self.cursor] = min(rating, self.min_ratings[self.cursor])
def get_quality(self):
"""获取所有谜题的最低评分
仅在回溯模式 (retronly) 下可用.
Returns:
所有谜题评分的最小值
Raises:
IndexError: 非回溯模式下调用
"""
if self.puzzles_inf:
if self.is_state("retronly", self):
return reduce(lambda x, y: min(x, y), self.min_ratings)
+2 -45
View File
@@ -14,12 +14,6 @@ class Procession(Machine):
"""队列: 标识单次记忆流程"""
def __init__(self, atoms: list, route_state: RouterState, name_: str = ""):
logger.debug(
"Procession.__init__: 原子数量=%d, route=%s, name='%s'",
len(atoms),
route_state.value,
name_,
)
self.current_atom: pt.Atom | None
self.atoms = atoms
self.current_atom = atoms[0] if atoms else None
@@ -52,88 +46,51 @@ class Procession(Machine):
initial=ProcessionState.ACTIVE.value,
)
logger.debug("Procession 初始化完成, 队列长度=%d", len(self.atoms))
def on_active(self):
"""进入active状态时的回调"""
logger.debug("Procession 进入 active 状态")
pass
def on_finished(self):
"""进入FINISHED状态时的回调"""
logger.debug("Procession 进入 FINISHED 状态")
pass
def forward(self, step=1):
"""将记忆原子指针向前移动并依情况更新原子(返回 1)或完成队列(返回 0)"""
logger.debug("Procession.forward: step=%d, 当前 cursor=%d", step, self.cursor)
self.cursor += step
if self.cursor >= len(self.atoms):
if self.state != ProcessionState.FINISHED.value:
self.finish() # 触发状态转换
logger.debug("Procession 已完成")
else:
if self.state != ProcessionState.ACTIVE.value:
self.restart() # 确保在active状态
self.current_atom = self.atoms[self.cursor]
logger.debug("cursor 更新为: %d", self.cursor)
logger.debug(
"当前原子更新为: %s",
self.current_atom.ident if self.current_atom else "None",
)
def append(self, atom=None):
"""追加(回忆失败的)原子(默认为当前原子)到队列末端"""
if atom is None:
atom = self.current_atom
logger.debug("Procession.append: atom=%s", atom.ident if atom else "None")
if not self.atoms or self.atoms[-1] != atom or len(self) <= 1:
self.atoms.append(atom)
logger.debug("原子已追加到队列, 新队列长度=%d", len(self.atoms))
else:
logger.debug("原子未追加(重复或队列长度<=1)")
def __len__(self):
if not self.atoms:
return 0
length = len(self.atoms) - self.cursor
logger.debug("Procession.__len__: 剩余长度=%d", length)
return length
def process(self):
"""获取当前游标位置
Returns:
当前游标索引
"""
logger.debug("Procession.process: cursor=%d", self.cursor)
return self.cursor
def total_length(self):
"""获取队列总长度 (含已处理的原子)
Returns:
原子总数
"""
total = len(self.atoms)
logger.debug("Procession.total_length: %d", total)
return total
def is_empty(self):
"""判断队列是否为空
Returns:
True 表示队列为空
"""
empty = len(self.atoms) == 0
logger.debug("Procession.is_empty: %s", empty)
return empty
def get_expander(self):
"""获取当前原子的展开器
Returns:
Expander 实例
"""
return Expander(atom=self.current_atom, route=self.route) # type: ignore
def __repr__(self, style="pipe", ends="\n"):
+7 -22
View File
@@ -14,7 +14,7 @@ class Router(Machine):
"""全局调度阶段路由器"""
def __init__(self, atoms: list[pt.Atom]) -> None:
logger.debug(f"Router.__init__: 原子数量={len(atoms)}")
logger.debug(f"number of atoms={len(atoms)}")
self.atoms = atoms
new_atoms = list()
@@ -26,7 +26,7 @@ class Router(Machine):
else:
old_atoms.append(i)
logger.debug(f"新原子数量={len(new_atoms)}, 旧原子数量={len(old_atoms)}")
logger.debug(f"number of new atoms={len(new_atoms)}, number of old atoms={len(old_atoms)}")
self.processions = list()
"""路由中的所有队列"""
@@ -35,17 +35,14 @@ class Router(Machine):
self.processions.append(
Procession(old_atoms, RouterState.QUICK_REVIEW, "初始复习")
)
logger.debug("创建初始复习 Procession")
if len(new_atoms):
self.processions.append(
Procession(new_atoms, RouterState.RECOGNITION, "新记忆")
)
logger.debug("创建新记忆 Procession")
self.processions.append(Procession(atoms, RouterState.FINAL_REVIEW, "总体复习"))
logger.debug("创建总体复习 Procession")
logger.debug("Router 初始化完成, processions 数量=%d", len(self.processions))
logger.debug("Router inited, number of processions =%d", len(self.processions))
# 设置transitions状态机
states = [
@@ -91,37 +88,27 @@ class Router(Machine):
def on_unsure(self):
"""进入UNSURE状态时的回调"""
logger.debug("Router 进入 UNSURE 状态")
pass
def on_quick_review(self):
"""进入QUICK_REVIEW状态时的回调"""
logger.debug("Router 进入 QUICK_REVIEW 状态")
pass
def on_recognition(self):
"""进入RECOGNITION状态时的回调"""
logger.debug("Router 进入 RECOGNITION 状态")
pass
def on_final_review(self):
"""进入FINAL_REVIEW状态时的回调"""
logger.debug("Router 进入 FINAL_REVIEW 状态")
pass
def on_finished(self):
"""进入FINISHED状态时的回调"""
for i in self.atoms:
i.lock(1)
i.revise()
logger.debug("Router 进入 FINISHED 状态")
def current_procession(self):
"""获取当前未完成的队列
遍历所有队列, 返回第一个未完成的 Procession.
若全部完成则切换到 FINISHED 状态并返回占位队列.
Returns:
当前活跃的 Procession, 或占位 Procession (全部完成时)
"""
logger.debug("Router.current_procession 被调用")
for i in self.processions:
i: Procession
if i.state != ProcessionState.FINISHED.value:
@@ -133,12 +120,10 @@ class Router(Machine):
elif i.route == RouterState.FINAL_REVIEW:
self.to_final_review()
logger.debug("找到未完成的 Procession: route=%s", i.route)
return i
# 所有Procession都已完成
self.to_finished()
logger.debug("所有 Procession 已完成, 状态设置为 FINISHED")
return Procession([AtomPlaceholder()], RouterState.FINISHED)
def __repr__(self, style="pipe", ends="\n"):
-14
View File
@@ -1,8 +1,3 @@
"""状态枚举定义
定义反应器三层状态机的所有状态值.
"""
from enum import Enum
from heurams.services.logger import get_logger
@@ -11,8 +6,6 @@ logger = get_logger(__name__)
class RouterState(Enum):
"""路由器状态: 全局复习阶段"""
UNSURE = "unsure"
QUICK_REVIEW = "quick_review"
RECOGNITION = "recognition"
@@ -21,17 +14,10 @@ class RouterState(Enum):
class ProcessionState(Enum):
"""队列状态: 单阶段进度"""
ACTIVE = "active"
FINISHED = "finished"
class ExpanderState(Enum):
"""展开器状态: 单原子调度模式"""
EXAMMODE = "exammode"
RETRONLY = "retronly"
logger.debug("状态枚举定义已加载")
-5
View File
@@ -1,8 +1,3 @@
"""仓库系统模块
提供记忆单元集的加载保存和管理功能.
"""
from .repo import Repo, RepoManifest
__all__ = ["Repo", "RepoManifest"]
+11 -81
View File
@@ -10,15 +10,6 @@ from heurams.kernel.auxiliary.lict import Lict
class RepoManifest(TypedDict):
"""仓库清单数据结构
Attributes:
title: 仓库标题
author: 作者名称
package: 包名标识
desc: 仓库描述
"""
title: str
author: str
package: str
@@ -26,21 +17,8 @@ class RepoManifest(TypedDict):
class Repo:
"""记忆单元仓库
管理单个记忆单元集的所有数据, 包括内容 (payload)算法状态 (algodata)
复习策略 (schedule) 和元信息 (manifest/typedef).
上层 API 请访问此对象下的粒子对象列表 (nucleonic_data_lict ).
Attributes:
schedule: 复习策略字典 (轨道定义)
payload: 记忆内容 (Lict)
algodata: 算法状态数据 (Lict)
manifest: 仓库清单信息
typedef: 类型定义和谜题配置
source: 仓库来源目录路径
"""
"""只维护仓库本身
上层 API 请访问此对象下粒子对象列表"""
file_mapping = {
"schedule": "schedule.toml",
@@ -89,16 +67,12 @@ class Repo:
}
try:
self.config.update(dict(config_var.get()["repo"][self.manifest["package"]]))
except (KeyError, TypeError, ValueError):
except:
pass
self._generate_particles_data()
def _generate_particles_data(self):
"""生成上层的粒子数据
payload 转换为 Nucleon 所需格式, 并为每个 ident 初始化
algodata 条目. 会在 __init__ 后自动调用.
"""
"""生成上层的粒子对象组和 API 交互, 会在 init 后自动调用"""
self.nucleonic_data_lict = Lict(
initlist=list(map(self._nucleonic_proc, self.payload))
)
@@ -139,7 +113,7 @@ class Repo:
with open(source / filename, "w") as f:
try:
dict_data = self.database[keyname].dicted_data
except AttributeError:
except:
dict_data = dict(self.database[keyname])
if filename.endswith("toml"):
toml.dump(dict_data, f)
@@ -154,14 +128,7 @@ class Repo:
@classmethod
def create_new_repo(cls, source=None):
"""创建新的空单元集
Args:
source: 可选的仓库目录路径
Returns:
包含空数据的 Repo 实例
"""
"""创建新的空单元集"""
default_database = {
"schedule": {},
"payload": Lict([]),
@@ -174,20 +141,7 @@ class Repo:
@classmethod
def from_repodir(cls, source: Path):
"""从目录创建单元集
读取目录中的 TOML/JSON 文件并构建 Repo 实例.
Args:
source: 仓库目录路径
Returns:
Repo 实例
Raises:
FileNotFoundError: 目录缺少必要的文件
ValueError: 文件格式不支持
"""
"""从目录创建单元集"""
database = {}
for keyname, filename in cls.file_mapping.items():
with open(source / filename, "r") as f:
@@ -209,47 +163,23 @@ class Repo:
@classmethod
def from_dict(cls, dictdata, source: Path | None = None):
"""从单一字典创建单元集
Args:
dictdata: 包含 schedule/payload/algodata/manifest/typedef 的字典
source: 可选的仓库目录路径
Returns:
Repo 实例
"""
"""从单一字典创建单元集"""
database = dictdata
database["source"] = source
return Repo(**database)
@classmethod
def check_repodir(cls, source: Path):
"""检测单元集目录合法性
尝试从目录加载 Repo, 成功则视为合法.
Args:
source: 待检测的目录路径
Returns:
True 表示合法, False 表示不合法
"""
"""检测单元集目录合法性"""
try:
cls.from_repodir(source)
return True
except (FileNotFoundError, KeyError, ValueError, toml.TomlDecodeError):
except:
return False
@classmethod
def probe_valid_repos_in_dir(cls, folder: Path):
"""扫描目录中的所有合法仓库子目录
Args:
folder: 待扫描的父目录路径
Returns:
合法仓库子目录的 Path 列表
"""
"""返回一个合法的子目录 Path() 列表"""
lst = list()
for i in folder.iterdir():
if i.is_dir():
+3
View File
@@ -0,0 +1,3 @@
[General]
LangCode=zh_CN
TargetLangCode=zh_CN
Binary file not shown.
@@ -0,0 +1,670 @@
# Japanese translations for HeurAMS.
# Copyright (C) 2026 Wang Zhiyu
#
msgid ""
msgstr ""
"Project-Id-Version: heurams 0.5.1\n"
"Language: ja\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
msgid "Welcome to the basic user interface!"
msgstr "基本ユーザーインターフェースへようこそ!"
msgid "Loading config and context... "
msgstr "設定とコンテキストを読み込み中… "
msgid "Done! ({time}ms)"
msgstr "完了!({time}ms"
msgid "Loading UI framework... "
msgstr "UI フレームワークを読み込み中… "
msgid "Loading UI layout... "
msgstr "UI レイアウトを読み込み中… "
msgid "Component directory: {path}"
msgstr "コンポーネントディレクトリ: {path}"
msgid "Working directory: {path}"
msgstr "作業ディレクトリ: {path}"
msgid "Pre-work total: {time}ms"
msgstr "前処理合計: {time}ms"
msgid "Heuristic Auxiliary Memorizing Scheduler"
msgstr "ヒューリスティック補助記憶スケジューラ"
msgid "Quit"
msgstr "終了"
msgid "Theme"
msgstr "テーマ"
msgid "Navigate"
msgstr "ナビゲート"
msgid "Settings"
msgstr "設定"
msgid "About"
msgstr "このソフトについて"
msgid "HeurAMS {ver} - Heuristic Auxiliary Memorizing Scheduler"
msgstr "HeurAMS {ver} - ヒューリスティック補助記憶スケジューラ"
msgid "Listening address"
msgstr "リッスンアドレス"
msgid "Listening port"
msgstr "リッスンポート"
msgid "Development mode hot reload"
msgstr "開発モードのホットリロード"
msgid "Show this help message"
msgstr "このヘルプを表示"
msgid "unifront API service started: http://{host}:{port}"
msgstr "unifront API サービス開始: http://{host}:{port}"
msgid "Show the version and exit."
msgstr "バージョンを表示して終了。"
msgid "Explicitly specify locale (defaults to LANG env)."
msgstr "UI ロケール(例: en_US, ja)。デフォルトは LANG 環境変数。"
msgid "Launch the built-in user interface (TUI)"
msgstr "内蔵 UITUI)を起動"
msgid "Launch the API service (unifront)"
msgstr "API サービス(unifront)を起動"
msgid "Dashboard"
msgstr "ダッシュボード"
msgid "Back"
msgstr "戻る"
msgid "Current daystamp: {ds}"
msgstr "現在の日付スタンプ: {ds}"
msgid "Timezone offset: UTC+{offset}"
msgstr "タイムゾーンオフセット: UTC+{offset}"
msgid "Default algorithm: {algo}"
msgstr "デフォルトアルゴリズム: {algo}"
msgid "Loaded {n} repo(s)"
msgstr "リポジトリ {n} 個を読み込み"
msgid "Total {n} unit(s)"
msgstr "ユニット合計 {n}"
msgid "Activated {n} unit(s)"
msgstr "アクティブ {n} ユニット"
msgid "Version {ver}-{stage}"
msgstr "バージョン {ver}-{stage}"
msgid "Processed {puzzles} puzzles in {time}s, accuracy {accuracy}, speed {speed} puzzle(s)/s"
msgstr "{time} 秒で {puzzles} 問処理, 正解率 {accuracy}, 速度 {speed} 問/秒"
msgid "N/A"
msgstr "N/A"
msgid ""
"No repo directories found in {path}.\n"
"Please import a repo and restart, or create a new one."
msgstr ""
"{path} にリポジトリが見つかりません。\n"
"リポジトリをインポートして再起動するか、新規作成してください。"
msgid "Start Learning"
msgstr "学習開始"
msgid ""
"{title} [{algo}]\n"
" [d]Progress: {touched}/{total} ({pct}%)[/d]\n"
" [d]{status}[/d]"
msgstr ""
"{title} [{algo}]\n"
" [d]進捗: {touched}/{total} ({pct}%)[/d]\n"
" [d]{status}[/d]"
msgid "Due: {review}R + {new}U"
msgstr "期限: {review}R + {new}U"
msgid "Not started: 0R + {new}U"
msgstr "未開始: 0R + {new}U"
msgid "Up to date"
msgstr "最新"
msgid "[b]About & Version Info[/b]"
msgstr "[b]バージョン情報[/b]"
msgid ""
"# About HeurAMS\n"
"\n"
"Main library version: `{ver}-python` \n"
"UI frontend: `Textual TUI (Basic UI)` \n"
"UI version: `{ver}` \n"
"API codename: `{codename}` \n"
"\n"
"> A heuristic auxiliary memorizing scheduler based on heuristic algorithms and cognitive science theories, designed to help users memorize and plan learning more efficiently. \n"
"> 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. \n"
"\n"
"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. \n"
"\n"
"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. \n"
"\n"
"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. \n"
"\n"
"Developers: \n"
"- Wang Zhiyu ([@pluvium27](https://github.com/pluvium27)): Project initiator and lead developer \n"
"\n"
"Special thanks to the following individuals and groups; their algorithms and theories form the cornerstone of the current software algorithms: \n"
"\n"
"- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2 algorithm and SM-15 algorithm theory \n"
"- [Jarrett Ye](https://github.com/L-M-Sherlock): FSRS algorithm and spaced repetition theory references \n"
"- [Kazuaki Tanida](https://github.com/slaypni): CoffeeScript reverse implementation of SM-15 algorithm \n"
"- [Open Spaced Repetition](https://github.com/open-spaced-repetition): FSRS algorithm underlying implementation \n"
"\n"
"# Runtime Environment\n"
"\n"
"Python interpreter version: {python_version} \n"
"Python interpreter path: {executable} \n"
"Textual framework version: {textual_version} \n"
"Terminal emulator: {terminal_info} \n"
"Operating system version: {os_version} \n"
"Disk free space: {disk_usage} \n"
"\n"
"When reporting issues, please copy this information into the issue description and attach `heurams.log` as an attachment to help developers locate the error."
msgstr ""
"# HeurAMS について\n"
"\n"
"メインライブラリバージョン: `{ver}-python` \n"
"UI フロントエンド: `Textual TUI(基本 UI` \n"
"UI バージョン: `{ver}` \n"
"API コードネーム: `{codename}` \n"
"\n"
"> ヒューリスティックアルゴリズムと認知科学理論に基づく補助記憶スケジューラ。 \n"
"> オープンで拡張可能な間隔反復実験プラットフォーム。 \n"
"\n"
"プロジェクトHP: https://ams.pluv27.top \n"
"\n"
"GNU AGPL-3.0 で公開。ローカル API 呼び出しの例外条項付き。 \n"
"\n"
"開発者: \n"
"- Wang Zhiyu ([@pluvium27](https://github.com/pluvium27)) \n"
"\n"
"謝辞: \n"
"- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2, SM-15 \n"
"- [Jarrett Ye](https://github.com/L-M-Sherlock): FSRS \n"
"- [Kazuaki Tanida](https://github.com/slaypni): SM-15 CoffeeScript 実装 \n"
"- [Open Spaced Repetition](https://github.com/open-spaced-repetition): FSRS 実装 \n"
"\n"
"# 実行環境\n"
"\n"
"Python: {python_version} \n"
"Python パス: {executable} \n"
"Textual: {textual_version} \n"
"端末: {terminal_info} \n"
"OS: {os_version} \n"
"空き容量: {disk_usage} \n"
"\n"
"問題報告時はこの情報を添付してください。"
msgid "Back to Main"
msgstr "メインに戻る"
msgid "Unknown"
msgstr "不明"
msgid "Requires a float"
msgstr "浮動小数点数が必要"
msgid "Requires a string"
msgstr "文字列が必要"
msgid "Requires an integer"
msgstr "整数が必要"
msgid "Unknown type"
msgstr "不明な型"
msgid "No sub-items"
msgstr "サブ項目なし"
msgid "Changes are saved immediately when you leave this page, but restart is recommended to ensure the new configuration is applied."
msgstr "このページを離れると変更は即保存されますが、確実に適用するには再起動を推奨します。"
msgid "Previous"
msgstr "前へ"
msgid "Read Aloud"
msgstr "読み上げ"
msgid "Favorite"
msgstr "お気に入り"
msgid "Learning"
msgstr "学習中"
msgid "Correct"
msgstr "正解"
msgid "Incorrect"
msgstr "不正解"
msgid "Failed to generate puzzle: {e}"
msgstr "パズル生成失敗: {e}"
msgid "Favorited"
msgstr "お気に入り済み"
msgid "Not favorited"
msgstr "未お気に入り"
msgid "Previous: {ident}"
msgstr "前: {ident}"
msgid "Are you sure? Press uppercase Q to go back."
msgstr "よろしいですか? 大文字 Q で戻ります。"
msgid "Cannot favorite: no repo associated"
msgstr "お気に入り不可: リポジトリが未関連"
msgid "Unfavorited: {ident}"
msgstr "お気に入り解除: {ident}"
msgid "Favorited: {ident}"
msgstr "お気に入り登録: {ident}"
msgid "This function is not available during memorization. Please finish or go back first."
msgstr "この機能は記憶中は使用できません。終了または戻ってからお試しください。"
msgid "Time resume corrected: {old} -> {new}"
msgstr "時間再開補正: {old} -> {new}"
msgid " [Debug Connected]"
msgstr " [デバッグ接続済]"
msgid "Favorites"
msgstr "お気に入り"
msgid "No favorites"
msgstr "お気に入りなし"
msgid "Press * in the memorization screen to add favorites."
msgstr "記憶画面で * キーを押すとお気に入りに追加。"
msgid "Total {n} favorite(s)"
msgstr "お気に入り合計 {n}"
msgid ""
" [d]Added: {time}\n"
" From {title}[/d]"
msgstr ""
" [d]追加: {time}\n"
" 出典: {title}[/d]"
msgid "Remove"
msgstr "削除"
msgid "Operation failed: invalid button identifier"
msgstr "操作失敗: 無効なボタンID"
msgid "Removed favorite: {ident}"
msgstr "お気に入り削除: {ident}"
msgid "Failed to remove: {ident}"
msgstr "削除失敗: {ident}"
msgid "Switch"
msgstr "切替"
msgid "Cache Manager"
msgstr "キャッシュ管理"
msgid "Settings Page"
msgstr "設定ページ"
msgid "Sync Tool"
msgstr "同期ツール"
msgid "Exit"
msgstr "終了"
msgid "Project Homepage"
msgstr "プロジェクトHP"
msgid ""
"[b]Select a function to navigate to\n"
"or a memorization session instance[/b]\n"
"\n"
"Tips will be displayed here"
msgstr ""
"[b]移動先の機能または\n"
"記憶セッションを選択[/b]\n"
"\n"
"ヒントがここに表示されます"
msgid ""
"Press Enter to switch\n"
"All sessions will be saved"
msgstr ""
"Enter で切替\n"
"全セッションは保存されます"
msgid "Close (n)"
msgstr "閉じる (n)"
msgid "Prepare Repository"
msgstr "リポジトリ準備"
msgid "Cache"
msgstr "キャッシュ"
msgid ""
"**Ready**: `{title}`\n"
""
msgstr ""
"**準備完了**: `{title}`\n"
""
msgid "Repo path: {path}"
msgstr "リポジトリパス: {path}"
msgid "Progress: {touched}/{total} [{pct}%]"
msgstr "進捗: {touched}/{total} [{pct}%]"
msgid "Scheduling algorithm: {algo} {desc}"
msgstr "スケジュールアルゴリズム: {algo} {desc}"
msgid ""
"Study count: {total} = {review} [d][Review][/d] + {new} [d][New][/d]\n"
""
msgstr ""
"学習数: {total} = {review} [d][復習][/d] + {new} [d][新規][/d]\n"
""
msgid "Start Memorizing"
msgstr "記憶開始"
msgid "Manage Cache"
msgstr "キャッシュ管理"
msgid "Sync protocol: {proto}"
msgstr "同期プロトコル: {proto}"
msgid "Server Configuration:"
msgstr "サーバー設定:"
msgid "Remote server:"
msgstr "リモートサーバー:"
msgid "Remote path:"
msgstr "リモートパス:"
msgid "Test Connection"
msgstr "接続テスト"
msgid "Start Sync"
msgstr "同期開始"
msgid "Pause"
msgstr "一時停止"
msgid "Cancel"
msgstr "キャンセル"
msgid "Sync Progress"
msgstr "同期進捗"
msgid "Sync Log"
msgstr "同期ログ"
msgid "Not configured"
msgstr "未設定"
msgid "Sync service ready"
msgstr "同期サービス準備完了"
msgid "Sync service not configured or not enabled"
msgstr "同期サービスが未設定または無効"
msgid "Failed to update UI: {error}"
msgstr "UI 更新失敗: {error}"
msgid "Failed to update status: {error}"
msgstr "ステータス更新失敗: {error}"
msgid "Sync service not initialised, please check configuration"
msgstr "同期サービスが未初期化。設定を確認してください。"
msgid "Sync service not initialised"
msgstr "同期サービス未初期化"
msgid "Testing WebDAV connection..."
msgstr "WebDAV 接続テスト中…"
msgid "Testing connection..."
msgstr "接続テスト中…"
msgid "Connection test successful"
msgstr "接続テスト成功"
msgid "Connection OK"
msgstr "接続正常"
msgid "Connection test failed"
msgstr "接続テスト失敗"
msgid "Connection failed"
msgstr "接続失敗"
msgid "Connection test error: {error}"
msgstr "接続テストエラー: {error}"
msgid "Connection error"
msgstr "接続エラー"
msgid "Sync service not initialised, cannot start sync"
msgstr "同期サービス未初期化のため開始不可"
msgid "Sync already in progress"
msgstr "同期は既に実行中"
msgid "Starting data sync..."
msgstr "データ同期開始…"
msgid "Syncing..."
msgstr "同期中…"
msgid "Syncing nucleon directory: {dir}"
msgstr "nucleon ディレクトリ同期中: {dir}"
msgid "Syncing electron directory: {dir}"
msgstr "electron ディレクトリ同期中: {dir}"
msgid "Syncing orbital directory: {dir}"
msgstr "orbital ディレクトリ同期中: {dir}"
msgid "nucleon sync complete: uploaded {up}, downloaded {down}"
msgstr "nucleon 同期完了: アップロード {up}, ダウンロード {down}"
msgid "electron sync complete: uploaded {up}, downloaded {down}"
msgstr "electron 同期完了: アップロード {up}, ダウンロード {down}"
msgid "orbital sync complete: uploaded {up}, downloaded {down}"
msgstr "orbital 同期完了: アップロード {up}, ダウンロード {down}"
msgid "Sync complete"
msgstr "同期完了"
msgid "All directories synced"
msgstr "全ディレクトリ同期完了"
msgid "Error during sync: {error}"
msgstr "同期エラー: {error}"
msgid "Sync failed"
msgstr "同期失敗"
msgid "Sync paused"
msgstr "同期一時停止"
msgid "Sync resumed"
msgstr "同期再開"
msgid "Sync cancelled"
msgstr "同期キャンセル"
msgid "Resume"
msgstr "再開"
msgid "Failed to update button state: {error}"
msgstr "ボタン状態更新失敗: {error}"
msgid "Unknown error"
msgstr "不明なエラー"
msgid "[b]Audio Pre-cache[/b]"
msgstr "[b]音声プリキャッシュ[/b]"
msgid "Cache rate: {rate:.1f}% ({cached} / {total} units)"
msgstr "キャッシュ率: {rate:.1f}% ({cached} / {total} ユニット)"
msgid "Target units from: [b]{desc}[/b]"
msgstr "対象ユニット: [b]{desc}[/b]"
msgid "Unit count: {n}"
msgstr "ユニット数: {n}"
msgid "Target: all units"
msgstr "対象: 全ユニット"
msgid "Start Pre-cache"
msgstr "プリキャッシュ開始"
msgid "Cancel Pre-cache"
msgstr "プリキャッシュ中止"
msgid "Clear Cache"
msgstr "キャッシュ消去"
msgid "Cache path: {path}"
msgstr "キャッシュパス: {path}"
msgid "Files: {n}"
msgstr "ファイル数: {n}"
msgid "Total size: {size}"
msgstr "合計サイズ: {size}"
msgid "Refresh"
msgstr "更新"
msgid "If you leave this screen, ongoing cache processes will stop automatically."
msgstr "この画面を離れるとキャッシュ処理は自動停止します。"
msgid "Cache supports \"resume from break\"."
msgstr "中断再開に対応しています。"
msgid "Ready"
msgstr "準備完了"
msgid "Waiting to start..."
msgstr "開始待機中…"
msgid "Status: {s}"
msgstr "ステータス: {s}"
msgid "Current item: {item}"
msgstr "現在の項目: {item}"
msgid "Processing ({i}/{total})"
msgstr "処理中 ({i}/{total})"
msgid "Error"
msgstr "エラー"
msgid "Failed, skipping: {item}"
msgstr "失敗、スキップ: {item}"
msgid "Cancelled"
msgstr "キャンセル済"
msgid "Pre-cache cancelled by user"
msgstr "ユーザーがプリキャッシュをキャンセル"
msgid "Cleared"
msgstr "消去済"
msgid "Audio cache cleared"
msgstr "音声キャッシュ消去済"
msgid "Failed to clear cache: {error}"
msgstr "キャッシュ消去失敗: {error}"
msgid "Cache info refreshed"
msgstr "キャッシュ情報更新完了"
msgid "This memorization session is finished"
msgstr "記憶セッション終了"
msgid "Algorithm data {}"
msgstr "アルゴリズムデータ{}"
msgid "saved"
msgstr "保存済"
msgid "not saved"
msgstr "未保存"
msgid "Back to Menu"
msgstr "メニューに戻る"
msgid "Perfect recall"
msgstr "完全に想起"
msgid "Correct after hesitation"
msgstr "ためらい後正解"
msgid "Correct with difficulty"
msgstr "困難だが正解"
msgid "Wrong but familiar"
msgstr "間違えたが見覚えあり"
msgid "Wrong and unfamiliar"
msgstr "間違えて見覚えなし"
msgid "Complete blank"
msgstr "まったく想起不可"
msgid "Evaluate how well you remember this content: "
msgstr "この内容の記憶程度を評価: "
msgid "### Note: {note}"
msgstr "### メモ: {note}"
msgid "I know this"
msgstr "分かっています"
msgid "Current input: {input}"
msgstr "現在の入力: {input}"
msgid "Backspace"
msgstr "BS"
msgid "Sample Label"
msgstr "サンプルラベル"
msgid "Sample Button"
msgstr "サンプルボタン"
Binary file not shown.
@@ -0,0 +1,669 @@
# Russian translations for HeurAMS.
# Copyright (C) 2026 Wang Zhiyu
#
msgid ""
msgstr ""
"Project-Id-Version: heurams 0.5.1\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
msgid "Welcome to the basic user interface!"
msgstr "Добро пожаловать в интерфейс!"
msgid "Loading config and context... "
msgstr "Загрузка конфигурации… "
msgid "Done! ({time}ms)"
msgstr "Готово! ({time}ms)"
msgid "Loading UI framework... "
msgstr "Загрузка UI… "
msgid "Loading UI layout... "
msgstr "Загрузка разметки… "
msgid "Component directory: {path}"
msgstr "Директория: {path}"
msgid "Working directory: {path}"
msgstr "Рабочая директория: {path}"
msgid "Pre-work total: {time}ms"
msgstr "Всего: {time}ms"
msgid "Heuristic Auxiliary Memorizing Scheduler"
msgstr "Эвристический планировщик запоминания"
msgid "Quit"
msgstr "Выход"
msgid "Theme"
msgstr "Тема"
msgid "Navigate"
msgstr "Навигация"
msgid "Settings"
msgstr "Настройки"
msgid "About"
msgstr "О программе"
msgid "HeurAMS {ver} - Heuristic Auxiliary Memorizing Scheduler"
msgstr "HeurAMS {ver} - Эвристический планировщик"
msgid "Listening address"
msgstr "Адрес"
msgid "Listening port"
msgstr "Порт"
msgid "Development mode hot reload"
msgstr "Горячая перезагрузка (dev)"
msgid "Show this help message"
msgstr "Показать справку"
msgid "unifront API service started: http://{host}:{port}"
msgstr "API запущен: http://{host}:{port}"
msgid "Show the version and exit."
msgstr "Показать версию и выйти."
msgid "Explicitly specify locale (defaults to LANG env)."
msgstr "Локаль (напр. en_US, ru). По умолчанию из LANG."
msgid "Launch the built-in user interface (TUI)"
msgstr "Запустить TUI"
msgid "Launch the API service (unifront)"
msgstr "Запустить API"
msgid "Dashboard"
msgstr "Панель"
msgid "Back"
msgstr "Назад"
msgid "Current daystamp: {ds}"
msgstr "Дата: {ds}"
msgid "Timezone offset: UTC+{offset}"
msgstr "Смещение: UTC+{offset}"
msgid "Default algorithm: {algo}"
msgstr "Алгоритм: {algo}"
msgid "Loaded {n} repo(s)"
msgstr "Загружено: {n}"
msgid "Total {n} unit(s)"
msgstr "Всего单元: {n}"
msgid "Activated {n} unit(s)"
msgstr "Активно: {n}"
msgid "Version {ver}-{stage}"
msgstr "Версия {ver}-{stage}"
msgid "Processed {puzzles} puzzles in {time}s, accuracy {accuracy}, speed {speed} puzzle(s)/s"
msgstr "Обработано {puzzles} задач за {time}с, точность {accuracy}, скорость {speed} задач/с"
msgid "N/A"
msgstr "Н/Д"
msgid ""
"No repo directories found in {path}.\n"
"Please import a repo and restart, or create a new one."
msgstr ""
"Репозитории не найдены в {path}.\n"
"Импортируйте или создайте новый."
msgid "Start Learning"
msgstr "Начать"
msgid ""
"{title} [{algo}]\n"
" [d]Progress: {touched}/{total} ({pct}%)[/d]\n"
" [d]{status}[/d]"
msgstr ""
"{title} [{algo}]\n"
" [d]Прогресс: {touched}/{total} ({pct}%)[/d]\n"
" [d]{status}[/d]"
msgid "Due: {review}R + {new}U"
msgstr "К сроку: {review}R + {new}U"
msgid "Not started: 0R + {new}U"
msgstr "Не начато: 0R + {new}U"
msgid "Up to date"
msgstr "Актуально"
msgid "[b]About & Version Info[/b]"
msgstr "[b]О программе[/b]"
msgid ""
"# About HeurAMS\n"
"\n"
"Main library version: `{ver}-python` \n"
"UI frontend: `Textual TUI (Basic UI)` \n"
"UI version: `{ver}` \n"
"API codename: `{codename}` \n"
"\n"
"> A heuristic auxiliary memorizing scheduler based on heuristic algorithms and cognitive science theories, designed to help users memorize and plan learning more efficiently. \n"
"> 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. \n"
"\n"
"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. \n"
"\n"
"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. \n"
"\n"
"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. \n"
"\n"
"Developers: \n"
"- Wang Zhiyu ([@pluvium27](https://github.com/pluvium27)): Project initiator and lead developer \n"
"\n"
"Special thanks to the following individuals and groups; their algorithms and theories form the cornerstone of the current software algorithms: \n"
"\n"
"- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2 algorithm and SM-15 algorithm theory \n"
"- [Jarrett Ye](https://github.com/L-M-Sherlock): FSRS algorithm and spaced repetition theory references \n"
"- [Kazuaki Tanida](https://github.com/slaypni): CoffeeScript reverse implementation of SM-15 algorithm \n"
"- [Open Spaced Repetition](https://github.com/open-spaced-repetition): FSRS algorithm underlying implementation \n"
"\n"
"# Runtime Environment\n"
"\n"
"Python interpreter version: {python_version} \n"
"Python interpreter path: {executable} \n"
"Textual framework version: {textual_version} \n"
"Terminal emulator: {terminal_info} \n"
"Operating system version: {os_version} \n"
"Disk free space: {disk_usage} \n"
"\n"
"When reporting issues, please copy this information into the issue description and attach `heurams.log` as an attachment to help developers locate the error."
msgstr ""
"# О HeurAMS\n"
"\n"
"Версия: `{ver}-python` \n"
"UI: `Textual TUI` \n"
"Версия UI: `{ver}` \n"
"Кодовое имя: `{codename}` \n"
"\n"
"> Эвристический планировщик запоминания на основе когнитивных наук. \n"
"\n"
"Сайт: https://ams.pluv27.top \n"
"\n"
"AGPL-3.0 с исключением для локальных API. \n"
"\n"
"Разработчик: \n"
"- Wang Zhiyu ([@pluvium27](https://github.com/pluvium27)) \n"
"\n"
"Благодарности: \n"
"- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2, SM-15 \n"
"- [Jarrett Ye](https://github.com/L-M-Sherlock): FSRS \n"
"- [Kazuaki Tanida](https://github.com/slaypni): SM-15 CoffeeScript \n"
"- [Open Spaced Repetition](https://github.com/open-spaced-repetition): FSRS \n"
"\n"
"# Окружение\n"
"\n"
"Python: {python_version} \n"
"Путь: {executable} \n"
"Textual: {textual_version} \n"
"Терминал: {terminal_info} \n"
"ОС: {os_version} \n"
"Диск: {disk_usage} \n"
"\n"
"Прикрепите эту информацию к баг-репорту."
msgid "Back to Main"
msgstr "На главную"
msgid "Unknown"
msgstr "Неизв."
msgid "Requires a float"
msgstr "Нужно число с точкой"
msgid "Requires a string"
msgstr "Нужна строка"
msgid "Requires an integer"
msgstr "Нужно целое"
msgid "Unknown type"
msgstr "Неизв. тип"
msgid "No sub-items"
msgstr "Нет подпунктов"
msgid "Changes are saved immediately when you leave this page, but restart is recommended to ensure the new configuration is applied."
msgstr "Изменения сохраняются сразу. Рекомендуется перезапуск."
msgid "Previous"
msgstr "Назад"
msgid "Read Aloud"
msgstr "Озвучить"
msgid "Favorite"
msgstr "Избранное"
msgid "Learning"
msgstr "Обучение"
msgid "Correct"
msgstr "Верно"
msgid "Incorrect"
msgstr "Неверно"
msgid "Failed to generate puzzle: {e}"
msgstr "Ошибка генерации: {e}"
msgid "Favorited"
msgstr "В избранном"
msgid "Not favorited"
msgstr "Не в избранном"
msgid "Previous: {ident}"
msgstr "Пред.: {ident}"
msgid "Are you sure? Press uppercase Q to go back."
msgstr "Уверены? Нажмите Q для возврата."
msgid "Cannot favorite: no repo associated"
msgstr "Ошибка: репозиторий не связан"
msgid "Unfavorited: {ident}"
msgstr "Удалено: {ident}"
msgid "Favorited: {ident}"
msgstr "Добавлено: {ident}"
msgid "This function is not available during memorization. Please finish or go back first."
msgstr "Функция недоступна во время запоминания."
msgid "Time resume corrected: {old} -> {new}"
msgstr "Время скорректировано: {old} -> {new}"
msgid " [Debug Connected]"
msgstr " [Отладка]"
msgid "Favorites"
msgstr "Избранное"
msgid "No favorites"
msgstr "Нет избранного"
msgid "Press * in the memorization screen to add favorites."
msgstr "Нажмите * для добавления в избранное."
msgid "Total {n} favorite(s)"
msgstr "Избранного: {n}"
msgid ""
" [d]Added: {time}\n"
" From {title}[/d]"
msgstr ""
" [d]Добавлено: {time}\n"
" Из: {title}[/d]"
msgid "Remove"
msgstr "Удалить"
msgid "Operation failed: invalid button identifier"
msgstr "Ошибка: неверный ID кнопки"
msgid "Removed favorite: {ident}"
msgstr "Удалено: {ident}"
msgid "Failed to remove: {ident}"
msgstr "Ошибка удаления: {ident}"
msgid "Switch"
msgstr "Переключить"
msgid "Cache Manager"
msgstr "Кеш"
msgid "Settings Page"
msgstr "Настройки"
msgid "Sync Tool"
msgstr "Синхр."
msgid "Exit"
msgstr "Выход"
msgid "Project Homepage"
msgstr "Сайт проекта"
msgid ""
"[b]Select a function to navigate to\n"
"or a memorization session instance[/b]\n"
"\n"
"Tips will be displayed here"
msgstr ""
"[b]Выберите функцию\n"
"или сеанс[/b]\n"
"\n"
"Подсказки здесь"
msgid ""
"Press Enter to switch\n"
"All sessions will be saved"
msgstr ""
"Enter для переключения\n"
"Сеансы будут сохранены"
msgid "Close (n)"
msgstr "Закрыть (n)"
msgid "Prepare Repository"
msgstr "Подготовка"
msgid "Cache"
msgstr "Кеш"
msgid ""
"**Ready**: `{title}`\n"
""
msgstr ""
"**Готово**: `{title}`\n"
""
msgid "Repo path: {path}"
msgstr "Путь: {path}"
msgid "Progress: {touched}/{total} [{pct}%]"
msgstr "Прогресс: {touched}/{total} [{pct}%]"
msgid "Scheduling algorithm: {algo} {desc}"
msgstr "Алгоритм: {algo} {desc}"
msgid ""
"Study count: {total} = {review} [d][Review][/d] + {new} [d][New][/d]\n"
""
msgstr ""
"Всего: {total} = {review} [d][Повтор][/d] + {new} [d][Новое][/d]\n"
""
msgid "Start Memorizing"
msgstr "Начать"
msgid "Manage Cache"
msgstr "Кеш"
msgid "Sync protocol: {proto}"
msgstr "Протокол: {proto}"
msgid "Server Configuration:"
msgstr "Сервер:"
msgid "Remote server:"
msgstr "Сервер:"
msgid "Remote path:"
msgstr "Путь:"
msgid "Test Connection"
msgstr "Тест"
msgid "Start Sync"
msgstr "Синхр."
msgid "Pause"
msgstr "Пауза"
msgid "Cancel"
msgstr "Отмена"
msgid "Sync Progress"
msgstr "Прогресс"
msgid "Sync Log"
msgstr "Лог"
msgid "Not configured"
msgstr "Не настроено"
msgid "Sync service ready"
msgstr "Синхр. готова"
msgid "Sync service not configured or not enabled"
msgstr "Синхр. не настроена"
msgid "Failed to update UI: {error}"
msgstr "Ошибка UI: {error}"
msgid "Failed to update status: {error}"
msgstr "Ошибка статуса: {error}"
msgid "Sync service not initialised, please check configuration"
msgstr "Синхр. не инициализирована"
msgid "Sync service not initialised"
msgstr "Не инициализирована"
msgid "Testing WebDAV connection..."
msgstr "Тест WebDAV…"
msgid "Testing connection..."
msgstr "Тест соединения…"
msgid "Connection test successful"
msgstr "Тест OK"
msgid "Connection OK"
msgstr "Соединение OK"
msgid "Connection test failed"
msgstr "Тест не удался"
msgid "Connection failed"
msgstr "Ошибка соединения"
msgid "Connection test error: {error}"
msgstr "Ошибка теста: {error}"
msgid "Connection error"
msgstr "Ошибка соединения"
msgid "Sync service not initialised, cannot start sync"
msgstr "Синхр. не иниц., запуск невозможен"
msgid "Sync already in progress"
msgstr "Синхр. уже выполняется"
msgid "Starting data sync..."
msgstr "Запуск синхр…"
msgid "Syncing..."
msgstr "Синхр…"
msgid "Syncing nucleon directory: {dir}"
msgstr "Синхр. nucleon: {dir}"
msgid "Syncing electron directory: {dir}"
msgstr "Синхр. electron: {dir}"
msgid "Syncing orbital directory: {dir}"
msgstr "Синхр. orbital: {dir}"
msgid "nucleon sync complete: uploaded {up}, downloaded {down}"
msgstr "nucleon: загружено {up}, скачано {down}"
msgid "electron sync complete: uploaded {up}, downloaded {down}"
msgstr "electron: загружено {up}, скачано {down}"
msgid "orbital sync complete: uploaded {up}, downloaded {down}"
msgstr "orbital: загружено {up}, скачано {down}"
msgid "Sync complete"
msgstr "Синхр. завершена"
msgid "All directories synced"
msgstr "Всё синхронизировано"
msgid "Error during sync: {error}"
msgstr "Ошибка синхр.: {error}"
msgid "Sync failed"
msgstr "Синхр. не удалась"
msgid "Sync paused"
msgstr "Пауза"
msgid "Sync resumed"
msgstr "Продолжено"
msgid "Sync cancelled"
msgstr "Отменено"
msgid "Resume"
msgstr "Продолжить"
msgid "Failed to update button state: {error}"
msgstr "Ошибка кнопки: {error}"
msgid "Unknown error"
msgstr "Неизв. ошибка"
msgid "[b]Audio Pre-cache[/b]"
msgstr "[b]Аудиокеш[/b]"
msgid "Cache rate: {rate:.1f}% ({cached} / {total} units)"
msgstr "Кеш: {rate:.1f}% ({cached} / {total})"
msgid "Target units from: [b]{desc}[/b]"
msgstr "Цель: [b]{desc}[/b]"
msgid "Unit count: {n}"
msgstr "Единиц: {n}"
msgid "Target: all units"
msgstr "Цель: всё"
msgid "Start Pre-cache"
msgstr "Начать"
msgid "Cancel Pre-cache"
msgstr "Отмена"
msgid "Clear Cache"
msgstr "Очистить"
msgid "Cache path: {path}"
msgstr "Путь кеша: {path}"
msgid "Files: {n}"
msgstr "Файлов: {n}"
msgid "Total size: {size}"
msgstr "Размер: {size}"
msgid "Refresh"
msgstr "Обновить"
msgid "If you leave this screen, ongoing cache processes will stop automatically."
msgstr "При выходе кеширование остановится."
msgid "Cache supports \"resume from break\"."
msgstr "Поддерживается возобновление."
msgid "Ready"
msgstr "Готово"
msgid "Waiting to start..."
msgstr "Ожидание…"
msgid "Status: {s}"
msgstr "Статус: {s}"
msgid "Current item: {item}"
msgstr "Элемент: {item}"
msgid "Processing ({i}/{total})"
msgstr "Обработка ({i}/{total})"
msgid "Error"
msgstr "Ошибка"
msgid "Failed, skipping: {item}"
msgstr "Ошибка, пропуск: {item}"
msgid "Cancelled"
msgstr "Отменено"
msgid "Pre-cache cancelled by user"
msgstr "Отменено пользователем"
msgid "Cleared"
msgstr "Очищено"
msgid "Audio cache cleared"
msgstr "Кеш очищен"
msgid "Failed to clear cache: {error}"
msgstr "Ошибка очистки: {error}"
msgid "Cache info refreshed"
msgstr "Инфо обновлено"
msgid "This memorization session is finished"
msgstr "Сеанс завершен"
msgid "Algorithm data {}"
msgstr "Данные алгоритма{}"
msgid "saved"
msgstr "сохранено"
msgid "not saved"
msgstr "не сохранено"
msgid "Back to Menu"
msgstr "В меню"
msgid "Perfect recall"
msgstr "Идеально"
msgid "Correct after hesitation"
msgstr "Верно после паузы"
msgid "Correct with difficulty"
msgstr "Верно с трудом"
msgid "Wrong but familiar"
msgstr "Неверно, но знакомо"
msgid "Wrong and unfamiliar"
msgstr "Неверно и незнакомо"
msgid "Complete blank"
msgstr "Полный провал"
msgid "Evaluate how well you remember this content: "
msgstr "Оцените запоминание: "
msgid "### Note: {note}"
msgstr "### Заметка: {note}"
msgid "I know this"
msgstr "Знаю"
msgid "Current input: {input}"
msgstr "Ввод: {input}"
msgid "Backspace"
msgstr "Забой"
msgid "Sample Label"
msgstr "Метка"
msgid "Sample Button"
msgstr "Кнопка"
+7
View File
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE martif PUBLIC 'ISO 12200:1999A//DTD MARTIF core (DXFcdV04)//EN' 'TBXcdv04.dtd'>
<martif type="TBX" xml:lang="en_US">
<text>
<body/>
</text>
</martif>
Binary file not shown.
@@ -0,0 +1,679 @@
# Chinese (Simplified) Language Pack for HeurAMS.
# Copyright (C) 2026 Wang Zhiyu
# This file is distributed under the same license as the HeurAMS project.
# Wang Zhiyu <pluvium27@outlook.com>, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: heurams 0.5.1\n"
"Report-Msgid-Bugs-To: https://github.com/pluvium27/HeurAMS/issues\n"
"POT-Creation-Date: 2026-05-21 00:00+0000\n"
"PO-Revision-Date: 2026-05-21 00:00+0000\n"
"Last-Translator: Wang Zhiyu <pluvium27@outlook.com>\n"
"Language-Team: Chinese (Simplified) <pluvium27@outlook.com>\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
msgid "Welcome to the basic user interface!"
msgstr "欢迎使用基本用户界面!"
msgid "Loading config and context... "
msgstr "加载配置与上下文... "
msgid "Done! ({time}ms)"
msgstr "已完成! (耗时: {time}ms)"
msgid "Loading UI framework... "
msgstr "加载用户界面框架... "
msgid "Loading UI layout... "
msgstr "加载用户界面布局... "
msgid "Component directory: {path}"
msgstr "组件目录: {path}"
msgid "Working directory: {path}"
msgstr "工作目录: {path}"
msgid "Pre-work total: {time}ms"
msgstr "前置工作共计耗时: {time}ms"
msgid "Heuristic Auxiliary Memorizing Scheduler"
msgstr "启发式辅助记忆调度器"
msgid "Quit"
msgstr "退出"
msgid "Theme"
msgstr "主题"
msgid "Navigate"
msgstr "导航"
msgid "Settings"
msgstr "设置"
msgid "About"
msgstr "关于"
msgid "HeurAMS {ver} - Heuristic Auxiliary Memorizing Scheduler"
msgstr "HeurAMS {ver} - 启发式辅助记忆调度器"
msgid "Listening address"
msgstr "监听地址"
msgid "Listening port"
msgstr "监听端口"
msgid "Development mode hot reload"
msgstr "开发模式热重载"
msgid "Show this help message"
msgstr "显示此帮助信息"
msgid "unifront API service started: http://{host}:{port}"
msgstr "unifront API 服务启动: http://{host}:{port}"
msgid "Show the version and exit."
msgstr "显示版本并退出."
msgid "Explicitly specify locale (defaults to LANG env)."
msgstr "显式指定语言 (默认使用 LANG 环境变量)."
msgid "Launch the built-in user interface (TUI)"
msgstr "启动内置基本用户界面 (TUI)"
msgid "Launch the API service (unifront)"
msgstr "启动 API 服务 (unifront)"
msgid "Dashboard"
msgstr "仪表盘"
msgid "Back"
msgstr "返回"
msgid "Current daystamp: {ds}"
msgstr "当前日时间戳: {ds}"
msgid "Timezone offset: UTC+{offset}"
msgstr "应用时区修正: UTC+{offset}"
msgid "Default algorithm: {algo}"
msgstr "默认算法设置: {algo}"
msgid "Loaded {n} repo(s)"
msgstr "已加载 {n} 个单元集"
msgid "Total {n} unit(s)"
msgstr "共计 {n} 个单元"
msgid "Activated {n} unit(s)"
msgstr "已激活 {n} 个单元"
msgid "Version {ver}-{stage}"
msgstr "版本 {ver}-{stage}"
msgid "Processed {puzzles} puzzles in {time}s, accuracy {accuracy}, speed {speed} puzzle(s)/s"
msgstr "在 {time} 秒内处理了 {puzzles} 个谜题, 正确率 {accuracy}, 平均速度 {speed} 个/秒"
msgid "N/A"
msgstr "无法求解"
msgid ""
"No repo directories found in {path}.\n"
"Please import a repo and restart, or create a new one."
msgstr ""
"在 {path} 中未找到任何单元集仓库目录.\n"
"请导入单元集后重启应用, 或者新建单元集."
msgid "Start Learning"
msgstr "开始学习"
msgid ""
"{title} [{algo}]\n"
" [d]Progress: {touched}/{total} ({pct}%)[/d]\n"
" [d]{status}[/d]"
msgstr ""
"{title} [{algo}]\n"
" [d]进度: {touched}/{total} ({pct}%)[/d]\n"
" [d]{status}[/d]"
msgid "Due: {review}R + {new}U"
msgstr "需要学习: {review}R + {new}U"
msgid "Not started: 0R + {new}U"
msgstr "暂未开始: 0R + {new}U"
msgid "Up to date"
msgstr "无需操作"
msgid "[b]About & Version Info[/b]"
msgstr "[b]关于与版本信息[/b]"
msgid ""
"# About HeurAMS\n"
"\n"
"Main library version: `{ver}-python` \n"
"UI frontend: `Textual TUI (Basic UI)` \n"
"UI version: `{ver}` \n"
"API codename: `{codename}` \n"
"\n"
"> A heuristic auxiliary memorizing scheduler based on heuristic algorithms and cognitive science theories, designed to help users memorize and plan learning more efficiently. \n"
"> 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. \n"
"\n"
"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. \n"
"\n"
"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. \n"
"\n"
"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. \n"
"\n"
"Developers: \n"
"- Wang Zhiyu ([@pluvium27](https://github.com/pluvium27)): Project initiator and lead developer \n"
"\n"
"Special thanks to the following individuals and groups; their algorithms and theories form the cornerstone of the current software algorithms: \n"
"\n"
"- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2 algorithm and SM-15 algorithm theory \n"
"- [Jarrett Ye](https://github.com/L-M-Sherlock): FSRS algorithm and spaced repetition theory references \n"
"- [Kazuaki Tanida](https://github.com/slaypni): CoffeeScript reverse implementation of SM-15 algorithm \n"
"- [Open Spaced Repetition](https://github.com/open-spaced-repetition): FSRS algorithm underlying implementation \n"
"\n"
"# Runtime Environment\n"
"\n"
"Python interpreter version: {python_version} \n"
"Python interpreter path: {executable} \n"
"Textual framework version: {textual_version} \n"
"Terminal emulator: {terminal_info} \n"
"Operating system version: {os_version} \n"
"Disk free space: {disk_usage} \n"
"\n"
"When reporting issues, please copy this information into the issue description and attach `heurams.log` as an attachment to help developers locate the error."
msgstr ""
"# 关于 HeurAMS\n"
"\n"
"主程序库版本: `{ver}-python` \n"
"用户界面分支: `Textual TUI (基本用户界面)` \n"
"用户界面版本: `{ver}` \n"
"API 版本代号: `{codename}` \n"
"\n"
"> 一个基于启发式算法与认知科学理论的辅助记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划. \n"
"> 一个开放, 优雅, 易于扩展的间隔重复调度器实验平台, 旨在帮助研究者更高效地进行前沿记忆算法的研究. \n"
"\n"
"您可在项目主页 https://ams.pluv27.top 获取用户指南, 开发文档与软件更新, 并参与到软件的开发与改进工作. \n"
"\n"
"以 GNU Affero 通用公共许可证 (第3版) 开放源代码, 并有一条豁免本机 API 调用的附加条款, 用于其他前端到程序库的接口调用. \n"
"\n"
"您正使用程序库内置的终端用户界面, 它是第一个全功能前端实现与程序库测试套件, 位于程序库的 interface 子目录. \n"
"\n"
"开发人员列表: \n"
"- Wang Zhiyu ([@pluvium27](https://github.com/pluvium27)): 项目发起与主要开发者 \n"
"\n"
"感谢以下人士与团体, 他们的算法与理论构成了此软件现有算法的基石: \n"
"\n"
"- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2 算法与 SM-15 算法理论 \n"
"- [Jarrett Ye](https://github.com/L-M-Sherlock): FSRS 算法与间隔重复理论文献参考 \n"
"- [Kazuaki Tanida](https://github.com/slaypni): SM-15 算法的 CoffeeScript 逆向实现 \n"
"- [Open Spaced Repetition](https://github.com/open-spaced-repetition): FSRS 算法底层实现 \n"
"\n"
"# 运行环境信息\n"
"\n"
"Python 解释器版本: {python_version} \n"
"Python 解释器路径: {executable} \n"
"Textual 框架版本: {textual_version} \n"
"终端模拟器: {terminal_info} \n"
"操作系统版本: {os_version} \n"
"存储余量: {disk_usage} \n"
"\n"
"报告问题时, 请复制这些信息到问题描述, 并上传软件日志 `heurams.log` 作为附件, 以协助开发者定位错误."
msgid "Back to Main"
msgstr "返回主界面"
msgid "Unknown"
msgstr "未知"
msgid "Requires a float"
msgstr "要求一个浮点数"
msgid "Requires a string"
msgstr "要求一个字符串"
msgid "Requires an integer"
msgstr "要求一个整数"
msgid "Unknown type"
msgstr "未知类型"
msgid "No sub-items"
msgstr "无子项"
msgid "Changes are saved immediately when you leave this page, but restart is recommended to ensure the new configuration is applied."
msgstr "退出页面时, 所作的更改会立即保存, 但仍建议重启软件以确保新的配置得到应用."
msgid "Previous"
msgstr "查看上一个"
msgid "Read Aloud"
msgstr "朗读"
msgid "Favorite"
msgstr "收藏"
msgid "Learning"
msgstr "学习中"
msgid "Correct"
msgstr "正确应答"
msgid "Incorrect"
msgstr "错误应答"
msgid "Failed to generate puzzle: {e}"
msgstr "无法生成谜题: {e}"
msgid "Favorited"
msgstr "已收藏"
msgid "Not favorited"
msgstr "未收藏"
msgid "Previous: {ident}"
msgstr "上一个: {ident}"
msgid "Are you sure? Press uppercase Q to go back."
msgstr "确定吗? 按下大写 Q 以返回."
msgid "Cannot favorite: no repo associated"
msgstr "无法收藏: 未关联仓库"
msgid "Unfavorited: {ident}"
msgstr "已取消收藏: {ident}"
msgid "Favorited: {ident}"
msgstr "已收藏: {ident}"
msgid "This function is not available during memorization. Please finish or go back first."
msgstr "功能在记忆界面中不可用, 完成或返回后再试."
msgid "Time resume corrected: {old} -> {new}"
msgstr "时间恢复已修正: {old} -> {new}"
msgid " [Debug Connected]"
msgstr " [调试已连接]"
msgid "Favorites"
msgstr "收藏夹"
msgid "No favorites"
msgstr "暂无收藏"
msgid "Press * in the memorization screen to add favorites."
msgstr "使用 * 键在记忆界面中添加收藏."
msgid "Total {n} favorite(s)"
msgstr "共 {n} 个收藏项"
msgid ""
" [d]Added: {time}\n"
" From {title}[/d]"
msgstr ""
" [d]添加于: {time}\n"
" 来自 {title}[/d]"
msgid "Remove"
msgstr "移除"
msgid "Operation failed: invalid button identifier"
msgstr "操作失败: 无效的按钮标识"
msgid "Removed favorite: {ident}"
msgstr "已移除收藏: {ident}"
msgid "Failed to remove: {ident}"
msgstr "移除失败: {ident}"
msgid "Switch"
msgstr "切换"
msgid "Cache Manager"
msgstr "缓存管理器"
msgid "Settings Page"
msgstr "设置页面"
msgid "Sync Tool"
msgstr "同步工具"
msgid "Exit"
msgstr "退出程序"
msgid "Project Homepage"
msgstr "项目主页"
msgid ""
"[b]Select a function to navigate to\n"
"or a memorization session instance[/b]\n"
"\n"
"Tips will be displayed here"
msgstr ""
"[b]请选择要跳转的功能\n"
"或记忆会话实例[/b]\n"
"\n"
"将在此处显示提示"
msgid ""
"Press Enter to switch\n"
"All sessions will be saved"
msgstr ""
"按下回车以完成切换\n"
"所有会话将被保存"
msgid "Close (n)"
msgstr "关闭 (n)"
msgid "Prepare Repository"
msgstr "准备记忆集"
msgid "Cache"
msgstr "缓存"
msgid ""
"**Ready**: `{title}`\n"
""
msgstr ""
"**准备就绪**: `{title}`\n"
""
msgid "Repo path: {path}"
msgstr "单元集路径: {path}"
msgid "Progress: {touched}/{total} [{pct}%]"
msgstr "学习完成度: {touched}/{total} [{pct}%]"
msgid "Scheduling algorithm: {algo} {desc}"
msgstr "调度算法: {algo} {desc}"
msgid ""
"Study count: {total} = {review} [d][Review][/d] + {new} [d][New][/d]\n"
""
msgstr ""
"学习数量: {total} = {review} [d][复习][/d] + {new} [d][新识记][/d]\n"
""
msgid "Start Memorizing"
msgstr "开始记忆"
msgid "Manage Cache"
msgstr "管理缓存"
msgid "Sync protocol: {proto}"
msgstr "同步协议: {proto}"
msgid "Server Configuration:"
msgstr "服务器配置:"
msgid "Remote server:"
msgstr "远程服务器:"
msgid "Remote path:"
msgstr "远程路径:"
msgid "Test Connection"
msgstr "测试连接"
msgid "Start Sync"
msgstr "开始同步"
msgid "Pause"
msgstr "暂停"
msgid "Cancel"
msgstr "取消"
msgid "Sync Progress"
msgstr "同步进度"
msgid "Sync Log"
msgstr "同步日志"
msgid "Not configured"
msgstr "未配置"
msgid "Sync service ready"
msgstr "同步服务已就绪"
msgid "Sync service not configured or not enabled"
msgstr "同步服务未配置或未启用"
msgid "Failed to update UI: {error}"
msgstr "更新 UI 失败: {error}"
msgid "Failed to update status: {error}"
msgstr "更新状态失败: {error}"
msgid "Sync service not initialised, please check configuration"
msgstr "同步服务未初始化, 请检查配置"
msgid "Sync service not initialised"
msgstr "同步服务未初始化"
msgid "Testing WebDAV connection..."
msgstr "正在测试 WebDAV 连接..."
msgid "Testing connection..."
msgstr "正在测试连接..."
msgid "Connection test successful"
msgstr "连接测试成功"
msgid "Connection OK"
msgstr "连接正常"
msgid "Connection test failed"
msgstr "连接测试失败"
msgid "Connection failed"
msgstr "连接失败"
msgid "Connection test error: {error}"
msgstr "连接测试异常: {error}"
msgid "Connection error"
msgstr "连接异常"
msgid "Sync service not initialised, cannot start sync"
msgstr "同步服务未初始化, 无法开始同步"
msgid "Sync already in progress"
msgstr "同步已在进行中"
msgid "Starting data sync..."
msgstr "开始同步数据..."
msgid "Syncing..."
msgstr "正在同步..."
msgid "Syncing nucleon directory: {dir}"
msgstr "同步 nucleon 目录: {dir}"
msgid "Syncing electron directory: {dir}"
msgstr "同步 electron 目录: {dir}"
msgid "Syncing orbital directory: {dir}"
msgstr "同步 orbital 目录: {dir}"
msgid "nucleon sync complete: uploaded {up}, downloaded {down}"
msgstr "nucleon 同步完成: 上传 {up} 个, 下载 {down} 个"
msgid "electron sync complete: uploaded {up}, downloaded {down}"
msgstr "electron 同步完成: 上传 {up} 个, 下载 {down} 个"
msgid "orbital sync complete: uploaded {up}, downloaded {down}"
msgstr "orbital 同步完成: 上传 {up} 个, 下载 {down} 个"
msgid "Sync complete"
msgstr "同步完成"
msgid "All directories synced"
msgstr "所有目录同步完成"
msgid "Error during sync: {error}"
msgstr "同步过程中发生错误: {error}"
msgid "Sync failed"
msgstr "同步失败"
msgid "Sync paused"
msgstr "同步已暂停"
msgid "Sync resumed"
msgstr "同步已恢复"
msgid "Sync cancelled"
msgstr "同步已取消"
msgid "Resume"
msgstr "继续"
msgid "Failed to update button state: {error}"
msgstr "更新按钮状态失败: {error}"
msgid "Unknown error"
msgstr "未知错误"
msgid "[b]Audio Pre-cache[/b]"
msgstr "[b]音频预缓存[/b]"
msgid "Cache rate: {rate:.1f}% ({cached} / {total} units)"
msgstr "缓存率: {rate:.1f}% (已缓存 {cached} / {total} 个单元)"
msgid "Target units from: [b]{desc}[/b]"
msgstr "目标单元归属: [b]{desc}[/b]"
msgid "Unit count: {n}"
msgstr "单元数量: {n}"
msgid "Target: all units"
msgstr "目标: 所有单元"
msgid "Start Pre-cache"
msgstr "开始预缓存"
msgid "Cancel Pre-cache"
msgstr "取消预缓存"
msgid "Clear Cache"
msgstr "清空缓存"
msgid "Cache path: {path}"
msgstr "缓存路径: {path}"
msgid "Files: {n}"
msgstr "文件数: {n}"
msgid "Total size: {size}"
msgstr "总大小: {size}"
msgid "Refresh"
msgstr "刷新"
msgid "If you leave this screen, ongoing cache processes will stop automatically."
msgstr "若您离开此界面, 未完成的缓存进程会自动停止."
msgid "Cache supports \"resume from break\"."
msgstr "缓存程序支持 [断点续传]."
msgid "Ready"
msgstr "就绪"
msgid "Waiting to start..."
msgstr "等待开始..."
msgid "Status: {s}"
msgstr "状态: {s}"
msgid "Current item: {item}"
msgstr "当前项目: {item}"
msgid "Processing ({i}/{total})"
msgstr "正处理 ({i}/{total})"
msgid "Error"
msgstr "出错"
msgid "Failed, skipping: {item}"
msgstr "处理失败, 已跳过: {item}"
msgid "Cancelled"
msgstr "已取消"
msgid "Pre-cache cancelled by user"
msgstr "预缓存操作被用户取消"
msgid "Cleared"
msgstr "已清空"
msgid "Audio cache cleared"
msgstr "音频缓存已清空"
msgid "Failed to clear cache: {error}"
msgstr "清空缓存失败: {error}"
msgid "Cache info refreshed"
msgstr "缓存信息已刷新"
msgid "This memorization session is finished"
msgstr "本次记忆进程结束"
msgid "Algorithm data {}"
msgstr "算法数据{}"
msgid "saved"
msgstr "已保存"
msgid "not saved"
msgstr "未能保存"
msgid "Back to Menu"
msgstr "返回上一级"
msgid "Perfect recall"
msgstr "完美回想"
msgid "Correct after hesitation"
msgstr "犹豫后正确"
msgid "Correct with difficulty"
msgstr "困难地正确"
msgid "Wrong but familiar"
msgstr "错误但熟悉"
msgid "Wrong and unfamiliar"
msgstr "错误且不熟"
msgid "Complete blank"
msgstr "完全空白"
msgid "Evaluate how well you remember this content: "
msgstr "请评估你对这个内容的记忆程度: "
msgid "### Note: {note}"
msgstr "### 笔记: {note}"
msgid "I know this"
msgstr "我已知晓"
msgid "Current input: {input}"
msgstr "当前输入: {input}"
msgid "Backspace"
msgstr "退格"
msgid "Sample Label"
msgstr "示例标签"
msgid "Sample Button"
msgstr "示例按钮"
+1 -1
View File
@@ -11,4 +11,4 @@ __all__ = [
]
providers = {"termux": termux_audio, "playsound": playsound_audio}
logger.debug("音频 providers 已注册: %s", list(providers.keys()))
logger.debug("Audio providers registered: %s", list(providers.keys()))
@@ -11,10 +11,10 @@ logger = get_logger(__name__)
def play_by_path(path: pathlib.Path):
logger.debug("playsound_audio.play_by_path: 开始播放 %s", path)
logger.debug("playsound_audio.play_by_path: playing %s", path)
try:
import playsound3
playsound3.playsound(str(path))
logger.debug("播放完成: %s", path)
logger.debug("Audio playing finished: %s", path)
except Exception as e:
logger.error("播放失败: %s, 错误: %s", path, e)
logger.error("Failed to play: %s, error: %s", path, e)
+3 -3
View File
@@ -13,9 +13,9 @@ logger = get_logger(__name__)
# from .protocol import PlayFunctionProtocol
def play_by_path(path: pathlib.Path):
logger.debug("termux_audio.play_by_path: 开始播放 %s", path)
logger.debug("termux_audio.play_by_path: playing %s", path)
try:
os.system(f"play-audio {path.resolve()}")
logger.debug("播放命令已执行: %s", path)
logger.debug("Play audio: %s", path)
except Exception as e:
logger.error("播放失败: %s, 错误: %s", path, e)
logger.error("Failed to play audio: %s, error: %s", path, e)
+1 -1
View File
@@ -16,4 +16,4 @@ providers = {
"openai": OpenAILLM,
}
logger.debug("LLM providers 已注册: %s", list(providers.keys()))
logger.debug("LLM providers registered: %s", list(providers.keys()))
-55
View File
@@ -1,55 +0,0 @@
"""LLM 提供者基类"""
import asyncio
from typing import Any, Dict, List
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class BaseLLM:
"""LLM 提供者基类"""
name = "BaseLLM"
def __init__(self, config: Dict[str, Any]):
"""初始化 LLM 提供者
Args:
config: 提供者配置字典
"""
self.config = config
logger.debug("BaseLLM 初始化完成")
async def chat(self, messages: List[Dict[str, str]], **kwargs) -> str:
"""发送聊天消息并获取响应
Args:
messages: 消息列表, 每个消息为 {"role": "user"|"assistant"|"system", "content": "消息内容"}
**kwargs: 其他参数, temperature, max_tokens
Returns:
模型返回的文本响应
"""
logger.debug("BaseLLM.chat: messages=%d, kwargs=%s", len(messages), kwargs)
logger.warning("BaseLLM.chat 是基类方法, 未实现具体功能")
await asyncio.sleep(0) # 避免未使用异步的警告
return "BaseLLM 未实现具体功能"
async def chat_stream(self, messages: List[Dict[str, str]], **kwargs):
"""流式聊天 (可选实现)
Args:
messages: 消息列表
**kwargs: 其他参数
Yields:
流式响应的文本块
"""
logger.debug(
"BaseLLM.chat_stream: messages=%d, kwargs=%s", len(messages), kwargs
)
logger.warning("BaseLLM.chat_stream 是基类方法, 未实现具体功能")
await asyncio.sleep(0)
yield "BaseLLM 未实现流式功能"
-95
View File
@@ -1,95 +0,0 @@
"""OpenAI 兼容 LLM 提供者"""
from typing import Any, AsyncGenerator, Dict, List
from heurams.services.logger import get_logger
from .base import BaseLLM
logger = get_logger(__name__)
class OpenAILLM(BaseLLM):
"""OpenAI 兼容 LLM 提供者"""
name = "OpenAI"
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.api_key = config.get("key", "")
self.base_url = config.get("url", "https://api.openai.com/v1")
self._client = None
logger.debug("OpenAILLM 初始化完成: base_url=%s", self.base_url)
def _get_client(self):
"""获取 OpenAI 客户端 (延迟导入) """
if self._client is None:
try:
from openai import AsyncOpenAI
except ImportError:
logger.error("未安装 openai 库, 请运行: pip install openai")
raise ImportError("未安装 openai 库, 请运行: pip install openai")
self._client = AsyncOpenAI(
api_key=self.api_key if self.api_key else None,
base_url=self.base_url if self.base_url else None,
)
return self._client
async def chat(self, messages: List[Dict[str, str]], **kwargs) -> str:
"""发送聊天消息并获取响应"""
logger.debug("OpenAILLM.chat: messages=%d", len(messages))
client = self._get_client()
# 默认参数
default_kwargs = {
"model": kwargs.get("model", "gpt-3.5-turbo"),
"temperature": kwargs.get("temperature", 0.7),
"max_tokens": kwargs.get("max_tokens", 1000),
}
# 合并参数, 优先使用传入的 kwargs
request_kwargs = {**default_kwargs, **kwargs}
request_kwargs["messages"] = messages
try:
response = await client.chat.completions.create(**request_kwargs)
content = response.choices[0].message.content
logger.debug(
"OpenAILLM.chat 成功: response length=%d",
len(content) if content else 0,
)
return content or ""
except Exception as e:
logger.error("OpenAILLM.chat 失败: %s", e)
raise
async def chat_stream(
self, messages: List[Dict[str, str]], **kwargs
) -> AsyncGenerator[str, None]:
"""流式聊天"""
logger.debug("OpenAILLM.chat_stream: messages=%d", len(messages))
client = self._get_client()
# 默认参数
default_kwargs = {
"model": kwargs.get("model", "gpt-3.5-turbo"),
"temperature": kwargs.get("temperature", 0.7),
"max_tokens": kwargs.get("max_tokens", 1000),
"stream": True,
}
# 合并参数
request_kwargs = {**default_kwargs, **kwargs}
request_kwargs["messages"] = messages
try:
stream = await client.chat.completions.create(**request_kwargs)
async for chunk in stream:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except Exception as e:
logger.error("OpenAILLM.chat_stream 失败: %s", e)
raise
+1 -1
View File
@@ -15,4 +15,4 @@ providers = {
"edgetts": EdgeTTS,
}
logger.debug("TTS providers 已注册: %s", list(providers.keys()))
logger.debug("TTS providers registered: %s", list(providers.keys()))
+1 -1
View File
@@ -12,5 +12,5 @@ class BaseTTS:
def convert(cls, text: str, path: pathlib.Path | str = "") -> pathlib.Path:
"""path 是可选参数, 不填则自动返回生成文件路径"""
logger.debug("BaseTTS.convert: text length=%d, path=%s", len(text), path)
logger.warning("BaseTTS.convert 是基类方法, 未实现具体功能")
logger.warning("BaseTTS.convert is not a functional implementation")
return path # type: ignore
+3 -3
View File
@@ -21,10 +21,10 @@ class EdgeTTS(BaseTTS):
text,
config_var.get()["providers"]["tts"]["edgetts"]["voice"],
)
logger.debug("EdgeTTS 通信对象创建成功, 正在保存音频")
logger.debug("EdgeTTS object created, saving audio")
communicate.save_sync(str(path))
logger.debug("EdgeTTS 音频已保存到: %s", path)
logger.debug("EdgeTTS audio saved as %s", path)
return path # type: ignore
except Exception as e:
logger.error("EdgeTTS.convert 失败: %s", e)
logger.error("EdgeTTS.convert failed: %s", e)
raise
+3 -4
View File
@@ -5,7 +5,7 @@ from heurams.context import config_var
from pathlib import Path
import atexit
from heurams.services import timer
from heurams.services.exceptions import AtticError
from heurams.services.exceptions import WTFException
logger = get_logger(__name__)
@@ -34,7 +34,7 @@ class Attic:
self.ident = self.ident.replace("<DAYSTAMP>", str(timer.get_daystamp()))
self.ident = self.ident.replace("<TIMESTAMP>", str(timer.get_timestamp()))
if "<" in ident or ">" in ident:
raise AtticError(f"Attic 标识 '{ident}' 中仍含有未替换的占位符")
raise WTFException
# self.ident = get_md5(self.ident)
self.pklpath = atticdir / f"{self.ident}.pkl"
atexit.register(self.save)
@@ -43,8 +43,7 @@ class Attic:
try:
self.load()
return
except (pkl.UnpicklingError, EOFError, ModuleNotFoundError, ImportError) as e:
logger.warning("Attic '%s' 加载失败, 将重建: %s", self.ident, e)
except:
self.pklpath.unlink(missing_ok=True)
self.pklpath.touch(exist_ok=True)
+2 -2
View File
@@ -11,7 +11,7 @@ logger = get_logger(__name__)
play_by_path: Callable = prov[
config_var.get()["services"]["audio"]["provider"]
].play_by_path
logger.debug(
"音频服务初始化完成, 使用 Provider: %s",
logger.info(
"TTS Service inited, using provider %s",
config_var.get()["services"]["audio"]["provider"],
)
+12 -21
View File
@@ -4,9 +4,9 @@ import toml
from collections import UserDict
from heurams.services.logger import get_logger
from heurams.services.exceptions import ConfigError
from heurams.services.exceptions import WTFException
# 流程: 找到文件名: 返回文件名里头的数据; 找不到: 继续查索引; 所以 self.data 除了存本级各种索引无他用
# 我们的流程: 找到文件名: 返回文件名里头的数据; 找不到: 继续查索引; 所以 self.data 除了存本级各种索引球用没得
logger = get_logger(__name__)
@@ -15,7 +15,7 @@ class ConfigDict(UserDict):
def __new__(cls, config_path: pathlib.Path, dict=None):
if dict:
raise ConfigError("ConfigDict 不接受默认字典参数")
raise WTFException("不要放默认值...")
# 规范化路径, 免得单例存在"别名"
path_key = config_path.resolve()
@@ -33,7 +33,7 @@ class ConfigDict(UserDict):
return
self._initialized = True
if dict:
raise ConfigError("ConfigDict 不接受默认字典参数")
raise WTFException("不要放默认值...")
super().__init__(dict)
logger = get_logger(__name__)
self.path = config_path
@@ -44,14 +44,12 @@ class ConfigDict(UserDict):
with open(self.path, "r+") as f: # TODO: 给这个做缓存
try:
self.data = toml.load(f)
self._writable = True
except toml.TomlDecodeError as e:
logger.warning("配置文件解析失败: %s, 将以空配置运行", e)
except:
self.data = {}
self._writable = False
self.persist = lambda: False # 不修改错误的配置文件
def __getitem__(self, key):
# 实现懒加载
# 我们实现了先进的懒狗加载
value = super().__getitem__(key)
if isinstance(value, pathlib.Path):
return ConfigDict(value)
@@ -61,10 +59,10 @@ class ConfigDict(UserDict):
return super().__contains__(key)
def __setitem__(self, key, value):
origvalue = super().__getitem__(key) # 所以不该访问不存在的对象
origvalue = super().__getitem__(key) # 所以不该访问不存在的对象
if isinstance(origvalue, ConfigDict):
if origvalue.path.is_dir():
raise ConfigError("不允许变更目录配置的内容")
raise WTFException("你怎么能变更目录配置的内容呢?!")
else:
# 对文件, 我们允许这种覆写存在
# 但是不准变类型
@@ -73,12 +71,8 @@ class ConfigDict(UserDict):
def update_index(
self,
): # 如果有人没事干在config里面创建指向config的符号链接 此函数会崩溃 但是不要修复: 需要这个符号链接特性并且真崩溃了绝对是用户故意的
): # 如果有人没事干在config里面创建指向config的符号链接 这玩意会崩溃 但是不要修复: 需要这个符号链接特性
for i in self.path.iterdir():
if i.name.startswith("."):
continue # 以防 OSX 与 dolphin 在目录产生隐藏废料
if i.name == "desktop.ini":
continue # 以防 Windows 产生废料
if i.name.startswith("_"):
if i.name == "_.toml" and not i.is_dir():
with open(self.path / "_.toml", "r+") as f:
@@ -90,18 +84,15 @@ class ConfigDict(UserDict):
if i.suffix == ".toml":
self.data[i.stem] = i
else:
logger.debug(f"配置目录中有无效的文件 {i.stem}") # what's up bro
logger.error(f"Illegal file detected in config: {i.stem}") # what's up bro
def persist(self):
if not getattr(self, "_writable", True):
logger.warning("跳过写入: 配置文件解析失败, 避免覆盖损坏的文件")
return
if self.is_dir:
for i in self.data.keys():
j = self[i]
if isinstance(j, ConfigDict):
j.persist()
logger.debug("完成配置持久化")
logger.info("Data persisted")
return
with open(self.path, "w+") as f:
+2 -4
View File
@@ -19,14 +19,12 @@ def epath(
path = path.lstrip(".")
target = dct
keys = path.split(".")
logger.debug(f"处理 EPATH {path}, {new_value}")
logger.debug(f"Proceeding EPATH {path}, {new_value}")
for idx, i in enumerate(keys):
is_last = idx == len(keys) - 1
# 处理字典键
logger.debug(
f"处理 {i}, {(isinstance(target, dict) or isinstance(target, ConfigDict))} {i in target}"
)
logger.debug(f"Proceeding in detail: {i}, {(isinstance(target, dict) or isinstance(target, ConfigDict))} {i in target}")
if is_last and enable_modify:
# 最后一次循环执行修改
+1 -9
View File
@@ -1,10 +1,2 @@
class HeurAMSError(Exception):
pass
class ConfigError(HeurAMSError):
pass
class AtticError(HeurAMSError):
class WTFException(Exception):
pass
+10 -10
View File
@@ -73,9 +73,9 @@ class FavoriteManager:
with open(self._file_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._favorites = [FavoriteItem.from_dict(item) for item in data]
logger.debug("收藏列表加载成功, 共 %d", len(self._favorites))
logger.info("Finished loading favlist, %d items in total", len(self._favorites))
except Exception as e:
logger.error("加载收藏列表失败: %s", e)
logger.error("Filed to load favlist: %s", e)
self._favorites = []
else:
self._favorites = []
@@ -86,9 +86,9 @@ class FavoriteManager:
data = [item.to_dict() for item in self._favorites]
with open(self._file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.debug("收藏列表保存成功, 共 %d", len(self._favorites))
logger.info("Finished saving favlist, %d items in total", len(self._favorites))
except Exception as e:
logger.error("保存收藏列表失败: %s", e)
logger.error("Failed to save favlist: %s", e)
def add(self, repo_path: str, ident: str, tags: List[str] | None = None) -> bool:
"""添加收藏
@@ -103,7 +103,7 @@ class FavoriteManager:
# 检查是否已存在
for item in self._favorites:
if item.repo_path == repo_path and item.ident == ident:
logger.debug("收藏已存在: %s/%s", repo_path, ident)
logger.info("Favitem already exists: %s/%s", repo_path, ident)
return False
item = FavoriteItem(
repo_path=repo_path,
@@ -113,7 +113,7 @@ class FavoriteManager:
)
self._favorites.append(item)
self.save()
logger.info("添加收藏: %s/%s", repo_path, ident)
logger.info("Add favitem: %s/%s", repo_path, ident)
return True
def remove(self, repo_path: str, ident: str) -> bool:
@@ -126,9 +126,9 @@ class FavoriteManager:
if item.repo_path == repo_path and item.ident == ident:
del self._favorites[idx]
self.save()
logger.info("移除收藏: %s/%s", repo_path, ident)
logger.info("Remove favitem: %s/%s", repo_path, ident)
return True
logger.debug("收藏不存在: %s/%s", repo_path, ident)
logger.error("Non-existed favitem: %s/%s", repo_path, ident)
return False
def has(self, repo_path: str, ident: str) -> bool:
@@ -139,7 +139,7 @@ class FavoriteManager:
return False
def get_all(self) -> List[FavoriteItem]:
"""获取所有收藏项 (按添加时间倒序) """
"""获取所有收藏项按添加时间倒序"""
return sorted(self._favorites, key=lambda x: x.added, reverse=True)
def get_by_repo(self, repo_path: str) -> List[FavoriteItem]:
@@ -150,7 +150,7 @@ class FavoriteManager:
"""清空收藏列表"""
self._favorites = []
self.save()
logger.info("清空收藏列表")
logger.info("Clear favlist")
def count(self) -> int:
"""收藏总数"""
+2 -6
View File
@@ -6,15 +6,11 @@ logger = get_logger(__name__)
def get_md5(text):
logger.debug(f"计算MD5哈希, 输入`{text}`")
logger.debug(f"MD5 hash input`{text}`")
result = hashlib.md5(text.encode("utf-8")).hexdigest()
logger.debug("哈希结果: %s...", result[:8])
logger.debug("Providing MD5 hash: %s...", result[:8])
return result
def hash(text):
# logger.debug(f"计算MD5-时间复合哈希, 输入`{text}`")
# result = hashlib.md5(f"{text}{random.randint(0,1000)}".encode("utf-8")).hexdigest()
# logger.debug("哈希结果: %s...", result[:8])
# return result
return get_md5(text)
+11 -63
View File
@@ -1,6 +1,6 @@
"""
HeurAMS 日志服务模块
基于Python标准logging, 提供统一日志记录功能
"""日志服务模块
基于 logging , 提供统一日志记录功能
"""
import logging
@@ -8,26 +8,24 @@ import logging.handlers
import pathlib
from typing import Optional, Union
# 默认配置
DEFAULT_LOG_LEVEL = logging.DEBUG
DEFAULT_LOG_FILE = pathlib.Path("heurams.log")
DEFAULT_LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
DEFAULT_LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d:%(funcName)s] - %(message)s"
DEFAULT_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
# 全局logger缓存
_loggers = {}
def setup_logging(
log_file: Union[str, pathlib.Path] = DEFAULT_LOG_FILE,
log_level: int = DEFAULT_LOG_LEVEL,
log_format: str = DEFAULT_LOG_FORMAT,
date_format: str = DEFAULT_DATE_FORMAT,
max_bytes: int = 10 * 1024 * 1024, # 10MB
max_bytes: int = 16 * 1024 * 1024, # 16MB
backup_count: int = 5,
) -> None:
"""
置全局日志系统
置全局日志服务
Args:
log_file: 日志文件路径
@@ -44,7 +42,7 @@ def setup_logging(
# 创建formatter
formatter = logging.Formatter(log_format, date_format)
# 创建文件handler(使用RotatingFileHandler防止日志过大)
# 创建文件 handler (RotatingFileHandler)
file_handler = logging.handlers.RotatingFileHandler(
filename=log_path,
maxBytes=max_bytes,
@@ -54,7 +52,6 @@ def setup_logging(
file_handler.setFormatter(formatter)
file_handler.setLevel(log_level)
# 配置root logger - 设置为 WARNING 级别(只记录重要信息)
root_logger = logging.getLogger()
root_logger.setLevel(logging.WARNING) # 这里改为 WARNING
@@ -62,7 +59,7 @@ def setup_logging(
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 创建自己的应用logger(单独设置DEBUG级别)
# 创建 heurams logger单独设置 DEBUG 级别
app_logger = logging.getLogger("heurams")
app_logger.setLevel(log_level) # 保持DEBUG级别
app_logger.addHandler(file_handler)
@@ -70,20 +67,8 @@ def setup_logging(
# 禁止传播到 root logger, 避免双重记录
app_logger.propagate = False
# 设置第三方库的日志级别为WARNING, 避免调试信息干扰
third_party_loggers = [
"markdown_it",
"markdown_it.rules_block",
"markdown_it.rules_core",
"markdown_it.rules_inline",
"asyncio",
]
for logger_name in third_party_loggers:
logging.getLogger(logger_name).setLevel(logging.WARNING)
# 记录日志系统初始化
app_logger.info("日志系统已初始化, 日志文件: %s", log_path)
app_logger.debug("HeurAMS logger inited, path: %s", log_path.resolve())
def get_logger(name: Optional[str] = None) -> logging.Logger:
@@ -106,49 +91,12 @@ def get_logger(name: Optional[str] = None) -> logging.Logger:
else:
logger_name = name
# 缓存logger以提高性能
# 缓存 logger 以提高性能, 以模块为单位的单例
if logger_name not in _loggers:
logger = logging.getLogger(logger_name)
_loggers[logger_name] = logger
return _loggers[logger_name]
# 便捷函数
def debug(msg: str, *args, **kwargs) -> None:
"""DEBUG级别日志"""
get_logger().debug(msg, *args, **kwargs)
def info(msg: str, *args, **kwargs) -> None:
"""INFO级别日志"""
get_logger().info(msg, *args, **kwargs)
def warning(msg: str, *args, **kwargs) -> None:
"""WARNING级别日志"""
get_logger().warning(msg, *args, **kwargs)
def error(msg: str, *args, **kwargs) -> None:
"""ERROR级别日志"""
get_logger().error(msg, *args, **kwargs)
def critical(msg: str, *args, **kwargs) -> None:
"""CRITICAL级别日志"""
get_logger().critical(msg, *args, **kwargs)
def exception(msg: str, *args, **kwargs) -> None:
"""记录异常信息 (ERROR级别)"""
get_logger().exception(msg, *args, **kwargs)
# 初始化日志系统(硬编码配置)
# 初始化日志系统
setup_logging()
# 模块级别的logger实例
logger = get_logger(__name__)
logger.info("HeurAMS日志服务模块已加载")
+21 -12
View File
@@ -1,4 +1,5 @@
# 时间服务
"""时间服务
"""
import datetime
import time
@@ -7,32 +8,40 @@ from heurams.services.logger import get_logger
logger = get_logger(__name__)
daystamp_override = config_var.get()["services"]["timer"]["daystamp_override"]
timestamp_override = config_var.get()["services"]["timer"]["timestamp_override"]
last_daystamp = 0
last_timestamp = 0
def get_daystamp() -> int:
"""获取当前日戳(以天为单位的整数时间戳)"""
time_override = config_var.get()["services"]["timer"]["daystamp_override"]
if time_override != -1:
logger.debug("使用覆盖的日戳: %d", time_override)
return int(time_override)
if daystamp_override != -1:
logger.debug("Daystamp overrode: %d", daystamp_override)
return int(daystamp_override)
result = int(
(time.time() + config_var.get()["services"]["timer"]["timezone_offset"])
// (24 * 3600)
)
logger.debug("计算日戳: %d", result)
global last_daystamp
if last_daystamp != result: # 用于避免日志泛洪
logger.debug("Providing new daystamp: %d", result)
last_daystamp = result
return result
def get_timestamp() -> float:
"""获取 UNIX 时间戳"""
# 搞这个函数的原因是要支持可复现操作
time_override = config_var.get()["services"]["timer"]["timestamp_override"]
if time_override != -1:
logger.debug("使用覆盖的时间戳: %f", time_override)
return float(time_override)
if timestamp_override != -1:
logger.debug("Timestamp overrode: %f", timestamp_override)
return float(timestamp_override)
result = time.time()
logger.debug("获取当前时间戳: %f", result)
global last_timestamp
if last_timestamp != result:
logger.debug("Providing new timestamp: %d", result)
last_timestamp = result
return result
@@ -51,7 +60,7 @@ def daystamp_to_datetime(daystamp: int) -> datetime.datetime:
def datetime_to_daystamp(dt: datetime.datetime) -> int:
"""将 datetime 转换为日戳 (从 1970-01-01 起的天数)
接受带时区或 naive datetime (naive 视为 UTC).
接受带时区或 naive datetime (naive 视为 UTC)
"""
epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
if dt.tzinfo is None:
+4 -3
View File
@@ -1,4 +1,5 @@
# 文本转语音服务
"""文本转语音服务
"""
from typing import Callable
from heurams.context import config_var
@@ -8,7 +9,7 @@ from heurams.services.logger import get_logger
logger = get_logger(__name__)
convertor: Callable = prov[config_var.get()["services"]["tts"]["provider"]].convert
logger.debug(
"TTS 服务初始化完成, 使用 provider: %s",
logger.info(
"TTS Service inited, using provider: %s",
config_var.get()["services"]["tts"]["provider"],
)
+2 -3
View File
@@ -1,4 +1,5 @@
# 版本控制集成服务
"""版本服务
"""
from heurams.services.logger import get_logger
logger = get_logger(__name__)
@@ -7,5 +8,3 @@ ver = "0.5.1"
stage = "stable"
codename = "fulcrum"
codename_cn = "支点"
logger.info("HeurAMS 版本: %s (%s), 阶段: %s", ver, codename, stage)
+1 -1
View File
@@ -128,7 +128,7 @@ def csv_to_toml(csv_path, toml_path=None, random_seed=None):
# 添加section标题
toml_content.append(f"[{ident}]")
# 添加所有其他列作为键值对 (排除ident列)
# 添加所有其他列作为键值对排除ident列
for key, value in row.items():
if key == "ident":
continue
+6
View File
@@ -0,0 +1,6 @@
"""unifront - HeurAMS API 前端模块"""
from .server import create_app
from .session import Session
__all__ = ["create_app", "Session"]
+92
View File
@@ -0,0 +1,92 @@
"""unifront 依赖注入"""
from __future__ import annotations
from pathlib import Path
import heurams.kernel.particles as pt
from heurams.context import config_var, workdir
from heurams.kernel.repolib.repo import Repo
from heurams.services.logger import get_logger
from .schemas import RepoInfo
logger = get_logger(__name__)
_repo_cache: dict[str, Repo] = {}
def get_data_repo_dir() -> Path:
"""获取仓库目录"""
return workdir / "data" / "repo"
def compute_repo_progress(repo: Repo) -> dict:
"""计算 repo 的学习进度"""
progress = {
"total": repo.data_length,
"touched": 0,
}
for i in range(repo.data_length):
try:
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict[i],
algo_name=repo.config["algorithm"],
)
if e.is_activated():
progress["touched"] += 1
except Exception:
pass
return progress
def get_repo(package: str) -> Repo | None:
"""按包名获取 Repo"""
if package in _repo_cache:
return _repo_cache[package]
repo_dir = get_data_repo_dir() / package
if not repo_dir.exists():
return None
try:
repo = Repo.from_repodir(repo_dir)
_repo_cache[package] = repo
return repo
except Exception as e:
logger.error("加载仓库 %s 失败: %s", package, e)
return None
def get_config():
"""获取配置对象"""
return config_var.get()
def list_repos() -> list[RepoInfo]:
"""列举可用仓库"""
from .schemas import RepoInfo
repo_dir = get_data_repo_dir()
valid_repos = Repo.probe_valid_repos_in_dir(repo_dir)
result = []
for rp in valid_repos:
if rp.name in _repo_cache:
repo = _repo_cache[rp.name]
else:
repo = Repo.from_repodir(rp)
_repo_cache[rp.name] = repo
progress = compute_repo_progress(repo)
result.append(
RepoInfo(
package=repo.manifest.get("package", rp.name),
title=repo.manifest.get("title", rp.name),
author=repo.manifest.get("author", ""),
desc=repo.manifest.get("desc", ""),
source=str(rp),
total=progress["total"],
touched=progress["touched"],
)
)
return result
+9
View File
@@ -0,0 +1,9 @@
"""unifront 路由"""
from .repos import router as repos_router
from .config import router as config_router
from .atoms import router as atoms_router
from .review import router as review_router
from .misc import router as misc_router
__all__ = ["repos_router", "config_router", "atoms_router", "review_router", "misc_router"]
+192
View File
@@ -0,0 +1,192 @@
"""原子信息 REST 路由"""
from fastapi import APIRouter, HTTPException
import heurams.kernel.particles as pt
import heurams.services.timer as timer
from heurams.context import config_var
from heurams.services.favorite_service import favorite_manager
from heurams.services.logger import get_logger
from ..dependencies import get_repo, compute_repo_progress
logger = get_logger(__name__)
router = APIRouter(prefix="/api/repos/{package}", tags=["atoms"])
def _safe_lastdate(e) -> int:
try:
return e.lastdate()
except (KeyError, TypeError):
return 0
@router.get("/atoms")
def list_atoms(package: str, page: int = 1, page_size: int = 50) -> dict:
"""获取仓库的原子列表"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404, detail=f"仓库 '{package}' 未找到")
idents = list(repo.ident_index)
total = len(idents)
start = (page - 1) * page_size
end = start + page_size
page_idents = idents[start:end]
items = []
for ident in page_idents:
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict.get_itemic_unit(ident),
algo_name=repo.config["algorithm"],
)
items.append({
"ident": ident,
"activated": bool(e.is_activated()),
"due": e.is_due(),
"rept": e.rept(real_rept=True),
"interval": e["interval"],
"next_date": e.nextdate(),
"last_date": _safe_lastdate(e),
})
return {"total": total, "page": page, "page_size": page_size, "items": items}
@router.get("/atoms/{ident}")
def get_atom(package: str, ident: str) -> dict:
"""获取单个原子的完整信息"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404, detail=f"仓库 '{package}' 未找到")
if ident not in repo.ident_index:
raise HTTPException(status_code=404, detail=f"原子 '{ident}' 未找到")
n = pt.Nucleon.from_data(
nucleonic_data=repo.nucleonic_data_lict.get_itemic_unit(ident)
)
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict.get_itemic_unit(ident),
algo_name=repo.config["algorithm"],
)
nucleon_data = {}
for key in n:
if key in ("puzzles",):
continue
try:
nucleon_data[key] = n[key]
except Exception:
pass
return {
"ident": ident,
"electron": {
"activated": bool(e.is_activated()),
"due": e.is_due(),
"rept": e.rept(real_rept=True),
"interval": e["interval"],
"next_date": e.nextdate(),
"last_date": _safe_lastdate(e),
"last_modify": e.last_modify(),
"algodata": e.algodata.get(e.algoname, {}),
},
"nucleon": nucleon_data,
}
@router.get("/prepare")
def prepare_repo(package: str) -> dict:
"""获取仓库复习准备数据(repo 信息 + 所有原子状态预览)"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404, detail=f"仓库 '{package}' 未找到")
progress = compute_repo_progress(repo)
today = timer.get_daystamp()
atoms = []
review_count = 0
new_count = 0
for i in repo.ident_index:
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict.get_itemic_unit(i),
algo_name=repo.config["algorithm"],
)
activated = bool(e.is_activated())
due = e.is_due()
if activated and due:
status = "R"
review_count += 1
elif activated:
status = "A"
else:
status = "U"
new_count += 1
n = pt.Nucleon.from_data(
nucleonic_data=repo.nucleonic_data_lict.get_itemic_unit(i)
)
content = n.get("content", "") or n.get("tts_text", "") or i
atoms.append({
"ident": i,
"status": status,
"rept": e.rept(real_rept=True),
"interval": e["interval"],
"next_date": e.nextdate(),
"content": str(content)[:60],
})
schedule_num = repo.config["scheduled_num"]
return {
"repo": {
"package": repo.manifest.get("package", package),
"title": repo.manifest.get("title", package),
"author": repo.manifest.get("author", ""),
"desc": repo.manifest.get("desc", ""),
"source": str(repo.source),
"algorithm": repo.config["algorithm"],
"scheduled_num": schedule_num,
},
"progress": progress,
"preview": {"review": review_count, "new": new_count},
"today": today,
"atoms": atoms,
"total_atoms": len(atoms),
}
@router.get("/atoms/{ident}/favorite")
def get_favorite(package: str, ident: str) -> dict:
"""检查原子是否已收藏"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404)
rel_path = _rel_repo_path(repo)
return {"favorited": favorite_manager.has(rel_path, ident)}
@router.post("/atoms/{ident}/favorite")
def toggle_favorite(package: str, ident: str) -> dict:
"""切换收藏状态"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404)
rel_path = _rel_repo_path(repo)
if favorite_manager.has(rel_path, ident):
favorite_manager.remove(rel_path, ident)
return {"favorited": False, "action": "removed"}
else:
favorite_manager.add(rel_path, ident)
return {"favorited": True, "action": "added"}
def _rel_repo_path(repo) -> str:
"""获取仓库相对路径"""
from heurams.context import workdir
data_repo = workdir / "data" / "repo"
try:
return str(repo.source.relative_to(data_repo))
except (ValueError, AttributeError):
return str(repo.source)
+99
View File
@@ -0,0 +1,99 @@
"""配置查询与修改 REST 路由"""
from fastapi import APIRouter, HTTPException
from heurams.services.config import ConfigDict
from heurams.services.epath import epath
from ..dependencies import get_config
router = APIRouter(prefix="/api/config", tags=["config"])
@router.get("")
def get_config_tree():
"""获取完整配置树"""
config = get_config()
return _build_tree(config)
def _build_tree(node, prefix: str = "") -> list[dict]:
"""通过 ConfigDict 的 __getitem__ 展开配置树"""
result = []
for key in node:
if key.startswith("_"):
continue
full_key = f"{prefix}.{key}" if prefix else key
child = node[key]
if isinstance(child, ConfigDict):
children = _build_tree(child, full_key)
result.append({"key": full_key, "type": "branch", "children": children})
# 收集_meta数据
for mk, mv in node.items():
if mk == f"_{key}_desc" and isinstance(mv, str):
if children:
children[0]["_meta_desc"] = mv
else:
item = {"key": full_key, "type": type(child).__name__, "value": child}
cand_key = f"_{key}_candidate"
desc_key = f"_{key}_desc"
if desc_key in node:
item["desc"] = node[desc_key]
if cand_key in node:
item["candidates"] = node[cand_key]
result.append(item)
return result
@router.get("/{section:path}")
def get_config_section(section: str):
"""获取指定配置节的详细数据(含元数据)"""
config = get_config()
keys = section.split("/")
current = config
for key in keys:
if key not in current:
return {}
current = current[key]
result = {}
for k, v in current.items():
if k.startswith("_"):
continue
item = {"value": v, "type": type(v).__name__}
cand_key = f"_{k}_candidate"
desc_key = f"_{k}_desc"
if cand_key in current:
item["candidates"] = current[cand_key]
if desc_key in current:
item["desc"] = current[desc_key]
result[k] = item
return result
@router.put("/{section:path}")
def update_config(section: str, body: dict):
"""更新单个配置项
body: {"value": ..., "path": "..."}
path 是可选的完整路径默认为 section
"""
config = get_config()
# 将 / 分隔的路径转为 . 分隔的 epath
path = body.get("path", section.replace("/", "."))
new_value = body.get("value")
if new_value is None:
raise HTTPException(status_code=400, detail="缺少 value")
# 尝试类型转换:保持原类型
old_val = epath(config, path)
if old_val is not None and type(old_val) != type(new_value):
try:
new_value = type(old_val)(new_value)
except (ValueError, TypeError):
pass
epath(config, path, enable_modify=True, new_value=new_value)
# 自动持久化
config.persist()
return {"ok": True, "path": path, "value": new_value}
+196
View File
@@ -0,0 +1,196 @@
"""收藏夹、系统信息、缓存管理与分析统计 API"""
import platform
import shutil
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from heurams.context import config_var
from heurams.services.attic import Attic
from heurams.services.favorite_service import favorite_manager
from heurams.services.logger import get_logger
from heurams.services.version import ver, stage, codename, codename_cn
logger = get_logger(__name__)
router = APIRouter(prefix="/api", tags=["misc"])
def _get_cache_dir() -> Path:
paths = config_var.get()["global"]["paths"]
cache = Path(paths.get("cache", str(Path(paths["data"]) / "cache"))) / "voice"
cache.mkdir(parents=True, exist_ok=True)
return cache
def _human_size(b: int) -> str:
for unit in ["B", "KB", "MB", "GB", "TB"]:
if b < 1024:
return f"{b:.1f} {unit}"
b /= 1024
return f"{b:.1f} PB"
@router.get("/cache")
def cache_info() -> dict:
"""缓存统计信息"""
cache_dir = _get_cache_dir()
total_size = 0
file_count = 0
if cache_dir.exists():
for f in cache_dir.rglob("*"):
if f.is_file():
total_size += f.stat().st_size
file_count += 1
return {
"path": str(cache_dir),
"file_count": file_count,
"total_size": total_size,
"human_size": _human_size(total_size),
"exists": cache_dir.exists(),
}
@router.delete("/cache")
def clear_cache() -> dict:
"""清空语音缓存"""
cache_dir = _get_cache_dir()
count = 0
if cache_dir.exists():
for f in cache_dir.rglob("*"):
if f.is_file():
f.unlink()
count += 1
# 移除空目录
for d in sorted(cache_dir.rglob("*"), key=lambda p: str(p), reverse=True):
if d.is_dir():
try:
d.rmdir()
except OSError:
pass
logger.info("清空缓存: 删除 %d 个文件", count)
return {"removed": count}
@router.get("/favorites")
def list_favorites() -> list[dict]:
"""获取所有收藏"""
items = []
for fav in favorite_manager.get_all():
items.append({
"repo_path": fav.repo_path,
"ident": fav.ident,
"added": fav.added,
"tags": fav.tags or [],
})
return items
@router.delete("/favorites/{repo_path}/{ident}")
def remove_favorite(repo_path: str, ident: str) -> dict:
"""移除收藏"""
if favorite_manager.remove(repo_path, ident):
return {"removed": True}
raise HTTPException(status_code=404, detail="收藏未找到")
@router.get("/analysis")
def analysis_stats() -> dict:
"""复习统计信息"""
a = Attic("ana", {"totaltime": 0, "openpuzzles": 0, "puzzles_err": 0})
total = a.data.get("openpuzzles", 0)
errs = a.data.get("puzzles_err", 0)
t = a.data.get("totaltime", 0)
rate = round(100 * (1 - errs / total), 2) if total else None
speed = round(total / t, 2) if t else None
return {
"total_puzzles": total,
"errors": errs,
"total_time": t,
"accuracy_pct": rate,
"speed_pps": speed,
}
@router.post("/cache/precache")
def trigger_precache() -> dict:
"""触发全量 TTS 缓存生成(后台异步)"""
import threading
from heurams.kernel.repolib import Repo
import heurams.kernel.particles as pt
from heurams.services.tts_service import convertor
from heurams.services.hasher import get_md5
paths_cfg = config_var.get()["global"]["paths"]
cache_dir = Path(paths_cfg.get("cache", str(Path(paths_cfg["data"]) / "cache"))) / "voice"
cache_dir.mkdir(parents=True, exist_ok=True)
repo_dir = Path(paths_cfg["data"]) / "repo"
def _run():
repos = Repo.probe_valid_repos_in_dir(repo_dir)
count = 0
for rp in repos:
try:
repo = Repo.from_repodir(rp)
for ident in repo.ident_index:
n = pt.Nucleon.from_data(repo.nucleonic_data_lict.get_itemic_unit(ident))
text = n.get("tts_text", "")
if not text:
continue
cache_file = cache_dir / f"{get_md5(text)}.wav"
if not cache_file.exists():
try:
convertor(text, cache_file)
count += 1
except Exception:
pass
except Exception:
continue
logger.info("预缓存完成: 生成 %d 个文件", count)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
return {"started": True, "message": "缓存生成已启动"}
@router.get("/tts")
def generate_tts(text: str):
"""生成 TTS 音频并返回 wav 文件"""
from heurams.services.hasher import get_md5
from heurams.services.tts_service import convertor
paths_cfg = config_var.get()["global"]["paths"]
cache_dir = Path(paths_cfg.get("cache", str(Path(paths_cfg["data"]) / "cache"))) / "voice"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / f"{get_md5(text)}.wav"
if not cache_file.exists():
try:
convertor(text, cache_file)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return FileResponse(str(cache_file), media_type="audio/wav", filename="tts.wav")
@router.get("/about")
def about_info() -> dict:
"""系统信息"""
disk = shutil.disk_usage("/")
is_venv = hasattr(sys, "real_prefix") or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)
return {
"version": ver,
"stage": stage,
"codename": codename,
"codename_cn": codename_cn,
"python_version": platform.python_version(),
"python_path": sys.executable,
"os": f"{platform.system()} {platform.release()}",
"platform": platform.platform(),
"disk_free_gb": round(disk.free / (1024**3), 1),
"disk_total_gb": round(disk.total / (1024**3), 1),
"disk_free_pct": round(disk.free / disk.total * 100, 1),
"in_virtualenv": is_venv,
}
+36
View File
@@ -0,0 +1,36 @@
"""仓库信息 REST 路由"""
from fastapi import APIRouter, HTTPException
from heurams.services.logger import get_logger
from ..dependencies import compute_repo_progress, get_repo, list_repos
from ..schemas import RepoInfo
logger = get_logger(__name__)
router = APIRouter(prefix="/api/repos", tags=["repos"])
@router.get("")
def list_all_repos() -> list[RepoInfo]:
"""获取所有可用仓库"""
return list_repos()
@router.get("/{package}")
def get_repo_info(package: str) -> RepoInfo:
"""获取单个仓库详细信息"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404, detail=f"仓库 '{package}' 未找到")
progress = compute_repo_progress(repo)
return RepoInfo(
package=repo.manifest.get("package", package),
title=repo.manifest.get("title", package),
author=repo.manifest.get("author", ""),
desc=repo.manifest.get("desc", ""),
source=str(repo.source),
total=progress["total"],
touched=progress["touched"],
)
+123
View File
@@ -0,0 +1,123 @@
"""复习 WebSocket 路由"""
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from heurams.services.logger import get_logger
from ..dependencies import get_repo
from ..session import Session, SessionError
logger = get_logger(__name__)
router = APIRouter()
# 活跃会话存储 {repo_name: Session}
_active_sessions: dict[str, Session] = {}
@router.websocket("/api/review/{package}")
async def review_websocket(ws: WebSocket, package: str):
"""复习会话 WebSocket 端点
消息协议 (JSON):
客户端 -> 服务端:
{"action": "start", "scheduled_num": 10}
{"action": "rate", "rating": 4}
服务端 -> 客户端:
{"type": "puzzle", "data": {...}}
{"type": "progress", "data": {"phase": "...", "current": 1, "total": 10}}
{"type": "finished", "data": {"repo_name": "...", "total_atoms": 10}}
{"type": "error", "message": "..."}
"""
await ws.accept()
logger.debug("WebSocket 连接: package=%s", package)
session: Session | None = None
try:
while True:
raw = await ws.receive_text()
msg = json.loads(raw)
action = msg.get("action")
if action == "start":
if session is not None:
await ws.send_json({"type": "error", "message": "会话已开始"})
continue
repo = get_repo(package)
if repo is None:
await ws.send_json({
"type": "error",
"message": f"仓库 '{package}' 未找到",
})
continue
try:
session = Session(repo)
scheduled_num = msg.get("scheduled_num", -1)
session.start(scheduled_num)
_active_sessions[session.id] = session
except SessionError as e:
await ws.send_json({"type": "error", "message": str(e)})
continue
# 发送初始进度和第一个谜题
await ws.send_json({
"type": "progress",
"data": session.progress,
})
puzzle = session.get_current_puzzle()
if puzzle:
await ws.send_json({"type": "puzzle", "data": puzzle})
else:
await ws.send_json({
"type": "finished",
"data": {"repo_name": package, "total_atoms": 0},
})
elif action == "rate":
if session is None or session.finished:
await ws.send_json({
"type": "error",
"message": "没有活跃的复习会话",
})
continue
rating = msg.get("rating", 3)
continuing = session.rate(rating)
if not continuing:
# 复习完成
await ws.send_json({
"type": "finished",
"data": {
"repo_name": package,
"total_atoms": session.repo.data_length,
},
})
else:
await ws.send_json({
"type": "progress",
"data": session.progress,
})
puzzle = session.get_current_puzzle()
if puzzle:
await ws.send_json({"type": "puzzle", "data": puzzle})
else:
await ws.send_json({
"type": "error",
"message": f"未知动作: {action}",
})
except WebSocketDisconnect:
logger.debug("WebSocket 断开: package=%s", package)
except Exception as e:
logger.error("WebSocket 错误: %s", e)
finally:
if session:
session.cleanup()
_active_sessions.pop(session.id, None)
+67
View File
@@ -0,0 +1,67 @@
"""unifront Pydantic 模型"""
from pydantic import BaseModel
class RepoInfo(BaseModel):
"""仓库信息"""
package: str
title: str
author: str
desc: str
source: str
total: int
touched: int
class AtomInfo(BaseModel):
"""原子信息"""
ident: str
activated: bool
due: bool
rept: int
interval: int
next_date: int
last_date: int
class ConfigTree(BaseModel):
"""配置树节点"""
key: str
type: str
value: object = None
children: list["ConfigTree"] | None = None
class PuzzlePayload(BaseModel):
"""谜题数据载荷"""
category: str
alia: str
atom_ident: str
puzzle: dict
class ReviewProgress(BaseModel):
"""复习进度"""
phase: str
current: int
total: int
class WSMessage(BaseModel):
"""WebSocket 消息基类"""
type: str
data: dict | None = None
message: str | None = None
class RateAction(BaseModel):
"""评分动作"""
rating: int # 0-5
+71
View File
@@ -0,0 +1,71 @@
"""unifront FastAPI 应用工厂"""
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from heurams.services.logger import get_logger
from heurams.services.version import ver
from heurams.context import rootdir
from .routes import repos_router, config_router, atoms_router, review_router, misc_router
logger = get_logger(__name__)
def create_app() -> FastAPI:
"""创建并配置 FastAPI 应用"""
app = FastAPI(
title="HeurAMS API",
version=ver,
description="HeurAMS 启发式辅助记忆调度器 - unifront API",
)
# CORS — 允许本地开发前端
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件 (Web 前端)
static_dir = rootdir / "unifront" / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# 注册路由
app.include_router(repos_router)
app.include_router(config_router)
app.include_router(atoms_router)
app.include_router(review_router)
app.include_router(misc_router)
@app.get("/api")
def api_root():
return {
"service": "HeurAMS",
"version": ver,
"endpoints": {
"repos": "/api/repos",
"config": "/api/config",
"atoms": "/api/repos/{package}/atoms",
"review_ws": "/api/review/{package}",
},
}
@app.get("/api/health")
def health():
return {"status": "ok", "version": ver}
@app.get("/")
def index():
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/static/index.html")
logger.info("unifront API 应用已创建")
return app
+239 -1
View File
@@ -1,5 +1,243 @@
"""会话模块"""
"""复习会话管理"""
import uuid
import inspect
import heurams.kernel.particles as pt
import heurams.kernel.reactor as rt
import heurams.kernel.puzzles as puz
from heurams.context import config_var
from heurams.kernel.repolib.repo import Repo
from heurams.services.logger import get_logger
logger = get_logger(__name__)
# 已知谜题类型及其构造参数白名单
_PUZZLE_PARAM_MAP = {
"MCQPuzzle": {"mapping", "jammer", "max_riddles_num", "prefix"},
"ClozePuzzle": {"text", "min_denominator", "delimiter"},
"RecognitionPuzzle": set(),
}
class SessionError(Exception):
"""会话错误"""
class Session:
"""管理单次复习会话的生命周期"""
def __init__(self, repo: Repo):
self.id = str(uuid.uuid4())[:8]
self.repo = repo
self.router: rt.Router | None = None
self.procession: rt.Procession | None = None
self.expander: rt.Expander | None = None
self.finished = False
def start(self, scheduled_num: int = -1):
"""开始复习,创建 Router and 处理第一个阶段"""
if scheduled_num == -1:
scheduled_num = config_var.get()["interface"]["global"]["scheduled_num"]
atoms = self._build_atoms()
atoms_to_provide = self._filter_atoms(atoms, scheduled_num)
if not atoms_to_provide:
raise SessionError("没有待复习的原子")
self.router = rt.Router(atoms_to_provide)
self._advance()
logger.debug("会话 %s 启动,原子数 %d", self.id, len(atoms_to_provide))
def _build_atoms(self) -> list[pt.Atom]:
"""从 repo 构建所有原子"""
atoms = []
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"],
)
a = pt.Atom(n, e, self.repo.orbitic_data)
atoms.append(a)
return atoms
def _filter_atoms(self, atoms: list[pt.Atom], scheduled_num: int) -> list[pt.Atom]:
"""筛选出待复习和新记忆的原子"""
result = []
left_new = scheduled_num
for atom in atoms:
if atom.registry["electron"].is_activated():
if atom.registry["electron"].is_due():
result.append(atom)
else:
left_new -= 1
if left_new >= 0:
result.append(atom)
return result
@property
def progress(self) -> dict:
"""当前复习进度"""
if not self.procession:
return {"phase": "unknown", "current": 0, "total": 0}
return {
"phase": self.procession.route.value,
"current": self.procession.process() + 1,
"total": self.procession.total_length(),
}
def _advance(self) -> bool:
"""推进到下一个阶段/原子。返回 False 表示全部完成"""
if not self.router:
return False
self.procession = self.router.current_procession()
if self.procession.route == rt.RouterState.FINISHED:
# 全部完成
self.finished = True
self._persist()
return False
self.expander = self.procession.get_expander()
return True
def get_current_puzzle(self) -> dict | None:
"""获取当前谜题的可序列化数据"""
if self.finished or not self.expander or not self.procession:
return None
puzzle_inf = self.expander.get_current_puzzle_inf()
puzzle_class = puzzle_inf["puzzle"]
alia = puzzle_inf["alia"]
atom = self.procession.current_atom
if self.expander.state == "retronly":
return {
"category": "recognition",
"alia": alia,
"atom_ident": atom.ident,
"phase": self.procession.route.value,
"puzzle": {
"content": atom.registry["nucleon"].get("content", ""),
"tts_text": atom.registry["nucleon"].get("tts_text", ""),
},
}
# 通过 atom 配置构造并刷新谜题
puzzle_cfg = atom.registry["nucleon"]["puzzles"].get(alia, {})
try:
# 过滤出 puzzle 构造器接受的参数
sig = inspect.signature(puzzle_class.__init__)
valid_params = set(sig.parameters.keys()) - {"self"}
filtered_cfg = {}
for k, v in puzzle_cfg.items():
if k not in valid_params:
continue
# TOML/Evalizer 可能返回字符串,尝试类型转换
if isinstance(v, str):
vs = v.strip()
try:
v = int(vs) if vs.isdigit() or (vs.startswith('-') and vs[1:].isdigit()) else float(vs)
except (ValueError, TypeError):
v = vs
filtered_cfg[k] = v
puz_instance = puzzle_class(**filtered_cfg) if filtered_cfg else puzzle_class()
puz_instance.refresh()
except Exception as e:
logger.warning("谜题生成失败 %s: %s", alia, e)
return {
"category": "unknown",
"alia": alia,
"atom_ident": atom.ident,
"phase": self.procession.route.value,
"puzzle": {"error": str(e)},
}
alias = puzzle_class.__name__.lower().replace("puzzle", "")
data = {
"category": alias,
"alia": alia,
"atom_ident": atom.ident,
"phase": self.procession.route.value,
"puzzle": self._serialize_puzzle(puz_instance),
}
return data
def _serialize_puzzle(self, puz) -> dict:
"""将谜题对象序列化为字典"""
data = {}
if hasattr(puz, "wording"):
data["wording"] = puz.wording
if hasattr(puz, "answer"):
data["answer"] = puz.answer
if hasattr(puz, "options"):
data["options"] = puz.options
if hasattr(puz, "prefix"):
data["primary"] = getattr(puz, "prefix", "")
if hasattr(puz, "primary"):
# 对于 MCQ,从 atom puzzle config 获取 primary
pass
# 补充 primary/提示字段
atom = self.procession.current_atom if self.procession else None
if atom:
alia = self.expander.get_current_puzzle_inf()["alia"] if self.expander else ""
cfg = atom.registry["nucleon"]["puzzles"].get(alia, {})
if "primary" in cfg:
data["primary"] = cfg["primary"]
return data
def rate(self, rating: int) -> bool:
"""评分当前谜题并推进,返回 False 表示所有流程完成"""
if self.finished or not self.expander or not self.procession:
return False
self.expander.report(rating)
# 决定是否向前推进(SM-2 尺度:>=4 表示正确)
if rating >= 4:
self.expander.forward()
# 如果是 retronly 阶段,处理原子完成
if self.expander.state == "retronly":
quality = self.expander.get_quality()
atom = self.procession.current_atom
# 报告评分给原子
if not atom.registry["electron"].is_activated():
atom.registry["electron"].activate()
atom.lock(1)
atom.minimize(5)
else:
atom.minimize(quality)
# 若质量差则放回队列
if quality <= 3 and atom:
self.procession.append()
# 前进到下一个原子
self.procession.forward(1)
self._advance()
# 检查当前阶段的 Procession 是否已完成
if self.procession and self.procession.state == rt.ProcessionState.FINISHED.value:
self._advance()
return not self.finished
def _persist(self):
"""保存 algodata 到文件"""
try:
self.repo.persist_to_repodir()
logger.debug("会话 %s: algodata 已持久化", self.id)
except Exception as e:
logger.warning("持久化失败: %s", e)
def cleanup(self):
"""清理会话"""
self.router = None
self.procession = None
self.expander = None
logger.debug("会话 %s 已清理", self.id)

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