7 Commits

Author SHA1 Message Date
pluv abd94d9a21 0.4.1 版本合并 2025-12-21 03:03:06 +08:00
pluv 91d3c86871 style(version): 更新版本号 2025-12-21 03:02:29 +08:00
pluv 66ad50c44d feat: 实验性 SM-15M 算法实现
实验性 SM-15M 逆向工程算法实现
2025-12-21 02:15:23 +08:00
pluv 2527daa923 style: isort 格式化 2025-12-19 15:13:42 +08:00
pluv 1883ca2387 style: 格式化代码 2025-12-19 15:08:26 +08:00
pluv dde6f872f0 fix(interface): 修复默认配置文件 2025-12-18 15:53:18 +08:00
pluv 16c22cf207 refactor: 完成 0.4.0 版本更新
完成 0.4.0 版本更新, 为了消除此前提交消息风格不一致与错误提交超大文件的问题, 维持代码统计数据的准确性和提交消息风格的一致性, 重新初始化仓库; 旧的提交历史在 HeurAMS-legacy 仓库(https://gitea.imwangzhiyu.xyz/ajax/HeurAMS-legacy)
2025-12-17 22:31:38 +08:00
149 changed files with 4822 additions and 13425 deletions
+9 -9
View File
@@ -5,20 +5,20 @@
__pycache__/ __pycache__/
.idea/ .idea/
cache/ cache/
data/repo/cngk #nucleon/test.toml
data/repo/eotgk electron/test.toml
data/repo/evtgk
data/misc
data/cache
data/session
*.egg-info/ *.egg-info/
build/ build/
dist/ dist/
old/ old/
AGENT.md # config/
data/cache/
data/electron/
data/nucleon/
!data/nucleon/test*
data/orbital/
AGENTS.md AGENTS.md
*.log.*
*.pkl
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
-467
View File
@@ -1,467 +0,0 @@
## 整体架构概览
```mermaid
graph TB
subgraph "用户界面层 (TUI)"
TUI[Textual App]
Screens[应用屏幕]
Widgets[谜题组件]
end
subgraph "内核层 Kernel"
Reactor[调度反应器]
Algorithms[算法模块]
Particles[数据模型]
Puzzles[谜题引擎]
RepoLib[仓库系统]
Auxiliary[辅助工具]
end
subgraph "服务层"
Config[配置管理 ConfigDict]
Logger[日志系统]
Timer[时间服务]
Audio[音频服务]
TTS[TTS 服务]
Favorites[收藏管理]
Attic[持久化]
Hasher[哈希服务]
end
subgraph "提供者层"
AudioProv[音频提供者]
TTSProv[TTS 提供者]
LLMProv[LLM 提供者]
end
subgraph "数据层"
RepoDir[TOML/JSON 仓库目录]
ConfigDir[TOML 配置目录]
Logs[日志文件]
end
TUI --> Screens
Screens --> Reactor
Screens --> RepoLib
Screens --> Widgets
Widgets --> Puzzles
Widgets --> Reactor
Reactor --> Algorithms
Reactor --> Particles
Reactor --> Puzzles
Particles --> RepoLib
RepoLib --> Config
RepoLib --> Auxiliary
Auxiliary --> Lict
Auxiliary --> Evalizer
TUI --> Config
TUI --> Logger
TUI --> Audio
TUI --> TTS
Config --> ConfigDir
Audio --> AudioProv
TTS --> TTSProv
Attic --> RepoDir
```
## 数据模型
项目以物理粒子隐喻为核心, 将记忆单元拆解为三个模型:
### Nucleon (核子) — 内容层
```
Nucleon(ident, payload, common)
```
- **只读**内容容器. 通过 `Evalizer` (基于 `eval()` 的模板系统)对 payload 和 common 进行编译展开.
- 包含 `puzzles` 字段, 定义该记忆单元支持哪些谜题类型.
-`repo.payload``repo.typedef["common"]` 配对创建.
- 一旦创建, 内容不可修改 (`__setitem__` 抛出 `AttributeError`).
### Electron (电子) — 状态层
```
Electron(ident, algodata, algo_name)
```
- 算法状态数据的包装器. 每个 Electron 绑定一个算法 (`algorithms[algo_name]`).
- `algodata` 是到仓库 `algodata.lict` 中对应字典的**引用**, 修改即持久化.
- 核心方法:`activate()` (标记激活)、`revisor()` (评分迭代)、`is_due()` (到期判断).
### Orbital (轨道) — 策略层
```
orbital = {
"schedule": ["quick_review", "recognition"],
"routes": {
"quick_review": [["MCQ", "1.0"], ["Cloze", "0.5"]],
"recognition": [["Recognition", "1.0"]],
}
}
```
- 定义复习阶段流程和各阶段内谜题选择策略的纯字典.
- 每个阶段对应一组 `(谜题类型, 概率系数)` 元组列表, 概率系数 >1 的部分表示强制重复次数.
### Atom (原子) — 运行时组装
```
Atom(nucleon, electron, orbital)
```
- 三者的运行时组合, 附带 `runtime` 运行时标志 (`locked`, `min_rate`, `new_activation`).
- 是 UI 和调度层操作的基本单位.
- `revise()` 方法在 `locked` 为真时调用 `electron.revisor(min_rate)`, 执行最终评分迭代.
**关系图**
```mermaid
graph LR
subgraph "持久化存储"
Payload[(payload.toml)]
Common[(typedef.toml)]
Algodata[(algodata.json)]
Schedule[(schedule.toml)]
end
subgraph "运行时组装"
Nucleon -->|内容| Atom
Electron -->|状态| Atom
Orbital -->|策略| Atom
end
Payload --> Nucleon
Common --> Nucleon
Algodata --> Electron
Schedule --> Orbital
```
______________________________________________________________________
## 调度反应器 (Reactor)
调度反应器是核心业务流程引擎, 采用三层嵌套的有限状态机设计 (基于 `transitions` 库).
### 状态枚举定义
| 状态机 | 状态 | 说明 |
|--------|------|------|
| **RouterState** | `unsure` | 初始状态, 自动推进 |
| | `quick_review` | 快速复习阶段 |
| | `recognition` | 新记忆识别阶段 |
| | `final_review` | 最终总复习阶段 |
| | `finished` | 完成, 执行评分 |
| **ProcessionState** | `active` | 进行中 |
| | `finished` | 已完成 |
| **ExpanderState** | `exammode` | 考试模式 (正面答题) |
| | `retronly` | 回溯模式 (仅识别) |
### 三层嵌套结构
```mermaid
graph TB
subgraph "Router (全局路由器)"
R[Router<br/>状态: unsure→quick_review→recognition→final_review→finished]
P1[Procession 队列1: 快速复习]
P2[Procession 队列2: 新记忆]
P3[Procession 队列3: 总复习]
R --> P1
R --> P2
R --> P3
end
subgraph "Procession (单阶段队列)"
P1 --> E1[Expander 原子A]
P1 --> E2[Expander 原子B]
P1 --> E3[Expander 原子C]
M{forward 推进} --> |完成| Finish((FINISHED))
end
subgraph "Expander (单原子展开器)"
E1 --> S[(轨道策略)]
S -->|概率展开| PZ1[谜题1: MCQ]
S -->|概率展开| PZ2[谜题2: Cloze]
PZ1 -->|评分| RPT[report]
PZ2 -->|评分| RPT
RPT -->|finish| RETRO[retronly 回溯模式]
end
```
### 数据流详解
```
Router.__init__(atoms)
├─ 新旧原子分流
│ ├─ old_atoms → Procession(quick_review) "初始复习"
│ └─ new_atoms → Procession(recognition) "新记忆"
└─ 所有原子 → Procession(final_review) "总体复习"
└─ Procession.forward()
├─ cursor >= len(atoms) → finish()
└─ cursor < len(atoms) → next_atom
└─ Procession.get_expander()
└─ Expander(atom, route)
├─ 读取 orbital.routes[route_value]
├─ 概率展开为谜题列表 self.puzzles_inf
├─ exammode → 依次展示谜题
├─ report(rating) → 记录最低评分
├─ forward() → 下一个谜题或 finish → retronly
└─ retronly → 展示 Recognition
└─ Atom.revise()
└─ Electron.revisor(min_rate)
└─ Algorithm.revisor(algodata, feedback)
```
______________________________________________________________________
## 算法系统
所有算法继承自 `BaseAlgorithm`, 以类方法的风格实现, 通过 `algorithms` 字典注册.
| 算法 | 文件 | 状态 | 说明 |
|------|------|------|------|
| **SM-2** | `sm2.py` | ✅ 完成 | 经典 SuperMemo 1987 算法 |
| **NSP-0** | `nsp0.py` | ✅ 完成 | 非间隔过滤调度器 |
| **SM-15M** | `sm15m.py` | ✅ 完成 | 从 CoffeeScript 移植的 SM-15 |
| **FSRS** | `fsrs.py` | ✅ 部分完成 | 优化器不可用 |
| **Base** | `base.py` | ✅ 基类 | 定义 `AlgodataDict` 结构和默认值 |
每个算法提供以下类方法:
| 方法 | 功能 |
|------|------|
| `revisor(algodata, feedback, is_new_activation)` | 根据评分迭代记忆数据 |
| `is_due(algodata)` | 判断是否到期复习 |
| `get_rating(algodata)` | 获取评分信息 |
| `nextdate(algodata)` | 获取下一次复习时间戳 |
| `check_integrity(algodata)` | 校验 algodata 数据结构完整性 |
### 算法数据结构 (AlgodataDict)
```python
{
"real_rept": int, # 实际复习次数
"rept": int, # 当前重复计数
"interval": int, # 间隔天数
"last_date": int, # 上次复习日期
"next_date": int, # 下次到期日期
"is_activated": int, # 是否已激活 (0/1)
"last_modify": float, # 最后修改时间戳
}
```
______________________________________________________________________
## 仓库系统 (Repo)
仓库是 TOML/JSON 文件目录, 无数据库依赖.
### 目录结构
```
data/repo/<package_name>/
├── manifest.toml # 元信息: title, author, package, desc
├── typedef.toml # 通用元数据、谜题定义、注解
├── payload.toml # 记忆条目 (key=ident)
├── algodata.json # 算法状态 (key=ident)
└── schedule.toml # 轨道/复习策略
```
### Repo 类设计
```mermaid
classDiagram
class Repo {
+dict schedule
+Lict payload
+dict manifest
+dict typedef
+Lict algodata
+Path source
+Lict nucleonic_data_lict
+dict orbitic_data
+Lict electronic_data_lict
+from_repodir(source) ~Repo
+from_dict(dictdata) ~Repo
+create_new_repo() ~Repo
+persist_to_repodir(save_list, source)
+export_to_dict() dict
}
```
- `payload``algodata` 使用 `Lict` (列表+字典混合容器), 支持双模式访问.
- `_generate_particles_data()` 在初始化时自动将 payload 数据转换为 `Nucleon` 所需的格式.
- 默认保存列表 `default_save_list = ["algodata"]`, 仅持久化算法状态.
______________________________________________________________________
## Lict 集合
`Lict` 继承 `MutableSequence`, 同时维护列表和字典访问:
```python
lict = Lict()
lict.append(("key1", value1)) # 列表追加
lict["key1"] # 字典访问
lict[0] # 索引访问
lict.keys() # 所有键
lict.dicted_data # 纯字典导出
```
脏同步机制:修改列表时自动同步字典, 修改字典时自动同步列表. 用于 `payload``algodata` 的双模式访问需求.
______________________________________________________________________
## 配置系统 (ConfigDict)
`ConfigDict` 继承 `UserDict`, 是**单例模式**的 TOML 懒加载配置管理器.
### 配置目录约定
```
data/config/
├── _.toml # 顶层默认值 (递归合并)
├── interface/
│ ├── _.toml # interface 层默认值
│ ├── global.toml
│ └── puzzles.toml
├── services/
│ ├── _.toml # services 层默认值
│ ├── audio.toml
│ └── tts.toml
└── repo/
└── _.toml
```
- `_.toml` 文件 = 该目录层级的默认值, 合并到父级
- 带后缀文件 = 按需懒加载
- 子目录 = 递归子配置
### 上下文管理
```python
from heurams.context import config_var, ConfigContext
# 全局访问
config = config_var.get()
algo = config["interface"]["global"]["algorithm"]
# 作用域覆盖
with ConfigContext(test_config):
... # 临时使用测试配置
```
______________________________________________________________________
## 提供者系统 (Providers)
可插拔的后端实现, 通过 `providers/__init__.py` 中的字典注册.
| 类别 | 提供者 | 说明 |
|------|--------|------|
| **TTS** | `edge_tts` | Microsoft Edge TTS (在线) |
| | `basetts` | 桩基类 (未实现) |
| **Audio** | `playsound` | 跨平台音频播放 |
| | `termux` | Android Termux 环境 |
| **LLM** | `openai` | OpenAI 兼容 API (未完整实现) |
选择方式:`services/*.toml` 中的 `provider` 字段.
______________________________________________________________________
## 谜题系统 (Puzzles)
谜题引擎用于在复习阶段生成评估视图:
| 谜题 | 文件 | 说明 |
|------|------|------|
| **MCQ** | `mcq.py` | 选择题 (Multiple Choice) |
| **Cloze** | `cloze.py` | 完形填空 (Cloze Deletion) |
| **Recognition** | `recognition.py` | 认读识别 |
| **Guess** | `guess.py` | 猜测词义 |
| **Base** | `base.py` | 抽象基类 |
谜题通过轨道策略 (Orbital)在 `Expander` 中按概率展开, 每个原子可产生多个谜题, 每个谜题独立评分.
______________________________________________________________________
## 服务层
| 服务 | 文件 | 说明 |
|------|------|------|
| **Config** | `config.py` | `ConfigDict(UserDict)` TOML 懒加载单例 |
| **Logger** | `logger.py` | `get_logger(name)` → 层级日志 (`heurams.*`) |
| **Timer** | `timer.py` | `get_daystamp()` / `get_timestamp()`, 支持可配置覆盖 |
| **Audio** | `audio_service.py` | 音频播放, 路由到配置的音频提供者 |
| **TTS** | `tts_service.py` | 文本转语音, 路由到配置的 TTS 提供者 |
| **Favorites** | `favorite_service.py` | JSON5 持久化的收藏管理器 (单例) |
| **Attic** | `attic.py` | 结构化 pickle 持久化, 支持 `<DAYSTAMP>`/`<TIMESTAMP>` 占位符 |
| **Hasher** | `hasher.py` | MD5 哈希 |
| **Epath** | `epath.py` | 点符号嵌套字典访问 (`epath(dct, "a.b.c")`) |
| **TextProc** | `textproc.py` | `truncate()`, `domize()`, `undomize()` |
日志系统:每个模块通过 `get_logger(__name__)` 创建自己的日志器, 日志文件 10MB 轮转, 最多 5 个备份, 追加到 `heurams.log`.
______________________________________________________________________
## 复习全流程
```mermaid
sequenceDiagram
participant User as 用户
participant UI as TUI
participant Router as Router
participant Procession as Procession
participant Expander as Expander
participant Atom as Atom
participant Electron as Electron
participant Algo as Algorithm
User->>UI: 开始复习
UI->>Router: Router(atoms)
Router->>Procession: 创建复习队列
Router->>Procession: 创建新记忆队列
Router->>Procession: 创建总复习队列
Procession->>Expander: 展开当前原子
Expander->>Expander: 解析轨道策略
Expander-->>UI: 展示谜题
User->>UI: 评分 (1-5)
UI->>Expander: report(rating)
Expander->>Expander: forward() 下一个谜题
Expander-->>UI: 下一个谜题或回溯
Expander->>Expander: finish() → retronly
Expander-->>UI: Recognition 回溯
User->>UI: 最终评分
UI->>Atom: revise()
Atom->>Electron: revisor(min_rate)
Electron->>Algo: revisor(algodata, feedback)
Algo-->>Electron: 更新 algodata
Algo-->>Atom: 更新 interval, next_date
Procession->>Procession: forward() 下一原子
Procession-->>Router: 队列完成
Router->>Router: 切换阶段
Router-->>UI: 完成 (finished)
UI->>User: 显示总结
```
______________________________________________________________________
## 关键设计决策
1. **无数据库** — 所有持久化基于 TOML/JSON 文件目录, 方便版本管理和手动编辑.
2. **Lict 双模式访问** — payload 和 algodata 同时支持列表迭代和字典查找, 兼顾批处理和随机访问.
3. **物理隐喻分离** — 内容 (Nucleon)、状态 (Electron)、策略 (Orbital) 三者正交, 可独立替换, 便于组合不同算法和内容类型.
4. **transitions 状态机** — 使用 `transitions` 库实现 Router → Procession → Expander 三层嵌套状态机, 每个层次职责明确.
5. **Evalizer eval 模板** — 使用 `eval()` 实现动态模板替换, 功能灵活但存在安全风险 (标记为待替换).
6. **配置单例**`ConfigDict` 以规范化路径为键实现单例, 避免多实例导致的配置不一致问题.
7. **评分累积** — 原子在多谜题阶段的最终评分取所有谜题的最低评分 (`min_rate`), 确保严格评估.
+22 -99
View File
@@ -1,116 +1,39 @@
# 贡献指南与二次开发 # 贡献指南
欢迎为此项目做出贡献! 欢迎为此项目做出贡献!
本项目是一个开源项目, 我们鼓励社区成员参与改进.
## 开发规范 ## 开发流程
分支划分:
- `main` 分支: 稳定版本, 仅当稳定版本释出或修补版本时将 `dev` 合并到 `main`
- `dev` 分支: 主线开发版本, 自身仅用于非重构的问题修复和整合功能分支
- 功能与重构分支: 从 `dev` 分支创建, 命名格式为 `feature/描述``fix/描述``refactor/v版本号`
- 不要将功能与重构分支先应被合并至 `dev` 后在 `dev` 完成文档开发后再释出至 `main`
代码格式化:
- 安装工具:
```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` 提交, 但 `main` 和 `dev` 分支上的代码应尽量整洁, 以便合并时审查
提交消息:
1. **分支划分**:
- `main` 分支: 稳定版本
- `dev` 分支: 开发版本
- 功能分支: 从 `dev` 分支创建, 命名格式为 `feature/描述``fix/描述``refactor/描述`
2. **代码风格**:
- 请使用 Black 格式化代码
- 遵循 PEP 8 规范
- 添加适当的文档字符串
3. **提交消息**:
- 使用简体中文或英文撰写清晰的提交消息 - 使用简体中文或英文撰写清晰的提交消息
- 提交消息格式: 遵循 [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 规范, 建议使用 `koji` 工具 - 格式: 遵循 Conventional Commits 规范
合并方式:
- 为了一致性和可追溯性, 项目自 v0.4.0 重构后重新初始化仓库起就禁止使用 Fast-forward 合并
- 可以设置 `git config merge.ff false`
## 设置开发环境 ## 设置开发环境
```bash ```bash
# 克隆仓库 # 克隆仓库
git clone https://git.pluv27.top/pluv/HeurAMS git clone https://gitea.imwangzhiyu.xyz/ajax/HeurAMS
cd HeurAMS cd HeurAMS
# 可能需要切换到 dev 分支 # 可能需要切换分支
git checkout dev
# 如果决定使用 uv (推荐) # 安装依赖
pip install -r requirements.txt
## 首先要安装uv, 例如通过 pip 或者其他包管理器 # 安装开发版本
python3 -m pip install uv pip install -e .
uv sync # 同步开发运行环境
uv run heurams # 验证包安装
uv run tui # 启动 TUI
# 如果决定使用原生 python 环境 (不推荐, 但我们保留了这种方式以便在不便支持 uv 与硬链接的环境和文件系统(例如 termux)运行 HeurAMS)
## 安装依赖并将 HeurAMS 安装为本地包
python3 -m pip install -r requirements.txt
python3 -m pip install -e .
python3 -m heurams # 验证安装
python3 -m heurams.__interface__ # 启动 TUI
``` ```
## 许可证与外部引用 ## 许可证
贡献者拥有其贡献部分的版权同意其贡献将在 AGPL-3.0 许可证下发布. 贡献者同意其贡献将在 AGPL-3.0 许可证下发布.
如果您认为有必要引入其他开源的 vendor, 请在 PR 中注明或手动联系以便我们审查 vendor 许可证并更改此处和网站上的关于与版权声明
如果您认为有必要引入其他专有的网络服务(就像现在项目中的 edgetts), 请也在 PR 中注明
如果您认为有必要升级某个依赖或运行环境的版本, 请也在 PR 中注明
## 新的用户界面前端与其他语言移植
HeurAMS 被设计为一个可独立于前端的程序库, 这意味着:
- 我们的内置 Textual TUI 前端不是唯一可用的前端
- 您可以在自己的项目中以独立进程/服务调用 HeurAMS (但不能在代码中链接), 而免于受 AGPL-3.0 "污染". 为了这点, 我们正在完善可选择启用的跨进程 RPC 模块, 这将成为潜进内核的跨平台标准件.
- 如果您有一个自己开发的且可用的 HeurAMS 前端 (例如我们暂未实现的 flutter 前端), 并且以 AGPL-3.0/GPL-3.0 开放源代码, 可以联系我们将它转移到 HeurAMS 的官方仓库中以便共同维护, 您将保留您的版权并可主导该仓库下的开发工作 :)
- 如果您通过独立进程/服务调用方式开发了另外的软件, 开源但不愿使用 AGPL-3.0/GPL-3.0 许可证, 也可以联系我们, 我们乐于将您的项目链接添加到友链中
- 如果您想创建程序库的其他语言 (例如 dart 或 rust) 版本以协助此语言下的方便集成, 并且同样以 AGPL-3.0/GPL-3.0 开放源代码, 也可以联系我们将它转移到 HeurAMS 的官方仓库中以便共同维护, 您将保留您的版权并可主导该仓库下的开发工作 :)
## 软件开发之外的贡献
即使您不是软件开发人员, 我们也欢迎您加入贡献!
您可以:
- 协助创建各种语言的翻译来翻译软件的界面 (但我们目前还没有 i18n 平台, 所以如果您想贡献翻译, 可能需要手动联系我们)
- 制作图像、主题、音效乃至制作开放的记忆单元集给其他用户使用
- 改进软件配套的文档
- 维护软件的开发/交流群组
- 给其他用户答疑解惑或分享自己的经验
- 在讨论区提出新想法或反馈问题
您的角色您来定!
+156 -116
View File
@@ -1,142 +1,182 @@
# 潜进 (HeurAMS) - 启发式辅助记忆调度器 # 潜进 (HeurAMS) - 启发式辅助记忆程序
## 概述 ## 概述
"潜进" (HeurAMS: Heuristic Auxiliary Memorizing Scheduler, 启发式记忆辅助调度器) 是为习题册, 古诗词, 及其他问答/记忆/理解型知识设计的多用途辅助记忆软件, 提供动态规划的优化记忆方案
"潜进" (HeurAMS: Heuristic Auxiliary Memorizing Scheduler, 启发式记忆辅助调度器) 是一个基于启发式算法与认知科学理论的辅助记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划, 也是一个开放, 优雅, 易于扩展的间隔重复调度器实验平台, 旨在帮助研究者更高效地进行前沿记忆算法的研究. ## 关于此仓库
"潜进" 软件组项目包含多个子项目
此仓库包含了 "潜进" 项目的核心和基于 Textual 的基本用户界面的实现
## 项目结构 ## 开发进程
- 0.0.x: 简易调度器实现与最小原型.
这个仓库是 "潜进" 的核心程序库在 python 语言下的实现\ - 0.1.x: 命令行操作的调度器.
包含数据模型与框架, 并内置了基于 textual 框架的前端实现 (interface 子模块)\ - 0.2.x: 使用 Textual 构建富文本终端用户界面, 项目可行性验证, 采用 SM-2 原始算法, 评估方式为用户自评估的原型.
除了通过内置前端进行学习外, 开发者也能在 python 环境中导入 `heurams` 库, 使用框架构建其他辅助记忆功能前端或其他应用程序 - 0.3.x: 简单的多文件项目, 创建了记忆内容/算法数据结构, 基于 SM-2 改进算法的自动复习测评评估. 重点设计古诗文记忆理解功能, 以及 TUI 界面实现, 简单的 TTS 集成.
- 0.4.x: 使用模块管理解耦设计, 增加文档与类型标注, 采用上下文设计模式的隐式依赖注入与遵从 IoC, 注册器设计的算法与功能实现, 支持其他调度算法模块 (SM-2, FSRS) 与谜题模块, 采用日志调试, 更新文件格式, 引入动态数据模式(宏驱动的动态内容生成), 与基于文件的策略调控, 更佳的用户数据处理, 加入模块化扩展集成, 将算法数据格式换为 json 提高性能, 采用 provider-service 抽象架构, 支持切换服务提供者, 整体兼容性改进.
> 下一步?
> 使用 Flutter 构建酷酷的现代化前端, 增加云同步/文档源服务...
## 特性 ## 特性
### 间隔重复调度器 ### 间隔迭代算法
> 许多出版物都广泛讨论了不同重复间隔对学习效果的影响. 特别是, 间隔效应被认为是一种普遍现象. 间隔效应是指, 如果重复的间隔是分散/稀疏的, 而不是集中重复, 那么学习任务的表现会更好. 因此, 有观点提出, 学习中使用的最佳重复间隔是**最长的、但不会导致遗忘的间隔**.
- 采用经实证的 SM-2 间隔迭代算法, 此算法亦用作 Anki 闪卡记忆软件的默认闪卡调度器
- 动态规划每个记忆单元的记忆间隔时间表
- 动态跟踪记忆反馈数据, 优化长期记忆保留率与稳定性
> 许多出版物都广泛讨论了不同重复间隔对学习效果的影响. 特别是, 间隔效应被认为是一种普遍现象. 间隔效应是指, 如果重复的间隔是分散/稀疏的, 而不是集中重复, 那么学习任务的表现会更好. 因此, 有观点提出, 学习中使用的最佳重复间隔是**最长的, 但不会导致遗忘的间隔**. ### 学习进程优化
- 逐字解析: 支持逐字详细释义解析
- 软件开箱即用, 无需多加配置即可使用默认的 `SM-2` 算法进行学习 - 语法分析: 接入生成式人工智能, 支持古文结构交互式解析
- 此外, 算法模块是 "潜进" 内核 (heurams.kernel) 中的一等公民, 内核天然支持插拔各型算法 - 自然语音: 集成微软神经网络文本转语音 (TTS) 技术
- 无需安装繁杂的插件即可分单元集完成算法快速切换与调优, 研究者可以方便地修改算法模块以便捷地进行研究与测试 - 多种谜题类型: 选择题 (MCQ)、填空题 (Cloze)、识别题 (Recognition)
- 内置 `SM-2` 简单间隔重复算法, 此算法亦用作 `Anki` 闪卡记忆软件的默认闪卡调度器 - 动态内容生成: 支持宏驱动的模板系统, 根据上下文动态生成题目
- 还内置 `NSP-0` 筛选用非间隔重复算法以便快速筛选记忆内容, `FSRS` 先进间隔重复算法作为效率更高的调度器, 与 `SM-15M (移植自 sm.js 项目)` 复杂间隔重复算法(逆向工程)
- 算法模块可以标记记忆项目, 也可以动态规划每个记忆单元的记忆间隔时间表, 动态跟踪记忆反馈数据, 以优化长期记忆保留率与稳定性
- 得益于项目的模块化架构与单元集结构设计, 一个项目甚至可以与任意种算法共存并互通, 这对研究者及想探索/实验高效率方法的用户极其友好
### 多模态学习进程
`Anki` 的 SQLite `apkg` 包不同, 潜进项目坚持使用人类可读的文件夹组织单元集, 这带来了若干好处, 包括:
- 人类可读: 您可以用任意工具, 乃至一个记事本自由修改记忆载荷数据而无需打开软件
- 元数据配置: 配置自由度极高, 可以任意组合, 重造, 乃至创造新内容
- 测验, 算法与知识互相隔离: 您的记忆项目不再是单一的闪卡, 而是 `载荷(payload)``谜题(puzzle)` 通过 `元数据(typedef)` 抽象成的 `核子(nucleon)` 对象, 在程序内部和 `算法数据(algodata)` 抽象成的 `电子`, `调度设置(schedule)` 定义的 `轨道(orbital)` 共同有机组合成的运行时对象 `原子(atom)`! 这意味着一条知识不仅可以用若干不同的算法规划, 还可以用多种并行的谜题类型测验, 极大地提升您的学习效果和丰富度. 作为学习者, 您无需担忧这些概念复杂--仅需从云端下载单元集即可开箱即用上述特性!
- 多模态学习
- 软件自身集成了文本转语音 (TTS) , 音频与语言模型 (LLM) 模块, 这些功能乃至功能本身都是可插拔, 可扩展, 可切换驱动的, 这为内容创建了极大的丰富度
- 软件内置多种谜题类型, 包括选择题 (MCQ), 填空题 (Cloze) 与识别题 (Recognition), 您可在同一单元应用多种, 或是选择启用
- 软件天然支持动态内容生成, 支持宏驱动的模板系统, 根据上下文乃至语言模型动态生成知识点的解析
- 在间隔重复研究尚被 SuperMemo 系列独占的时代, Wozniak 就早已表示 "如果不能理解知识, 就无需记忆它". 今天, 我们依然相信理解是记忆的基石
- 云同步与分享优化: 由于我们的记忆数据和单元集文件都是文本文件, 故可进行快速的增量同步而无需完整地上传所有文件, 并且设计天然支持分享内容的版本控制, 如果您想分享单文件, 我们也支持 .zip/.tar.gz/.tar.xz 导入与导出
- 性能提升: 得益于现代且支持分块的文件组织结构, 潜进能在保持高自由度的同时仅使用 python 就能达到敏捷且低占用的用户体验
### 实用用户界面 ### 实用用户界面
- 响应式 Textual 框架构建的跨平台 TUI 界面 - 响应式 Textual 框架构建的跨平台 TUI 界面
- 支持触屏/鼠标/键盘多操作模式 - 支持触屏/鼠标/键盘多操作模式
- 简洁直观的复习流程设计 - 简洁直观的复习流程设计
## 快速开始 ### 架构特性
- 模块化设计: 算法、谜题、服务提供者可插拔替换
- 上下文管理: 使用 ContextVar 实现隐式依赖注入
- 数据持久化: TOML 配置与内容, JSON 算法状态
- 服务抽象: 音频播放、TTS、LLM 通过 provider 架构支持多种后端
- 完整日志系统: 带轮转的日志记录, 便于调试
### 从包管理器安装 ## 安装
潜进(heurams) 处于早期开发考虑, 尚未上架 PyPI, 但您可以用我们的基础设施安装稳定版和开发版本.
> [!CAUTION]
> 对于部分 Linux 发行版和 Android Termux 用户:\
> 您需要先行安装 `cmake` 和 `libzmq` 才能正确安装项目的 `zmq` 依赖\
> 例如在 termux 上先运行 `pkg install cmake clang libzmq`\
> 项目功能本身不依赖它, 但需要该依赖用于启动可选的调试服务器
#### 稳定版本
```
python -m pip install heurams[all] -i https://pypi.pluv27.top/root/stable/+simple/ # 安装全部可选依赖(推荐)
```
#### 开发版本
```
python -m pip install heurams[all] -i https://pypi.pluv27.top/root/dev/+simple/ # 安装全部可选依赖(推荐)
```
#### 依赖组说明
由于部分依赖只被少数功能需要, 所以我们把可选依赖分得比较细, 前面的命令会安装所有可选依赖, 以下是依赖组列表
- 基础依赖: (只能驱动程序库)
- tabulate: 终端表格
- toml: TOML 文件加载
- transitions: 状态机依赖
- `interface` 依赖组: (基本用户界面依赖)
- textual: 终端用户界面
- psutil: 获取系统信息
- `algo-fsrs` 依赖组:
- py-fsrs: FSRS 算法模块需要
- `tts-edgetts` 依赖组:
- edge-tts:微软文本转语音
- `misc-jieba` 依赖组:
- jieba: 中文智能分词所需
- `llm-openai` 依赖组:
- openai: OpenAI API 所需
- `audio-playsound` 依赖组:
- playsound: 通用音频播放
- pygobject: playsound 依赖
- `dev` 依赖组:
- zmq: 远程调试服务器所需
- pytest: 测试所需
- pytest-cov: 测试所需
- `all` 依赖组:
- 包含以上所有依赖
### 从源码安装 ### 从源码安装
1. 克隆仓库:
```bash
git clone https://gitea.imwangzhiyu.xyz/ajax/HeurAMS
cd HeurAMS
```
我们提供原生 python 和 uv 两种安装方式.\ 2. 安装依赖:
详见[贡献指南](CONTRIBUTING.md). ```bash
pip install -r requirements.txt
```
3. 以开发模式安装包:
```bash
pip install -e .
```
## 使用
### 启动应用
```bash
# 在任一目录(建议是空目录或者包根目录, 将被用作存放数据)下运行
python -m heurams.interface
```
### 数据目录结构
应用会在工作目录下创建以下数据目录:
- `data/nucleon/`: 记忆内容 (TOML 格式)
- `data/electron/`: 算法状态 (JSON 格式)
- `data/orbital/`: 策略配置 (TOML 格式)
- `data/cache/`: 音频缓存文件
- `data/template/`: 内容模板
首次运行时会自动创建这些目录.
## 配置
配置文件位于 `config/config.toml`(相对于工作目录). 如果不存在, 会使用内置的默认配置.
## 项目结构 ## 项目结构
详见[架构说明](ARCHITECTURE.md). ### 架构图
## 参与项目 以下 Mermaid 图展示了 HeurAMS 的主要组件及其关系:
欢迎参与到项目协作中! 请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南. ```mermaid
graph TB
subgraph "用户界面层 (TUI)"
TUI[Textual TUI]
Widgets[界面组件]
Screens[应用屏幕]
end
subgraph "服务层"
Config[配置管理]
Logger[日志系统]
Timer[时间服务]
AudioService[音频服务]
TTSService[TTS服务]
OtherServices[其他服务]
end
subgraph "内核层"
Algorithms[算法模块]
Particles[数据模型]
Puzzles[谜题模块]
Reactor[调度反应器]
end
subgraph "提供者层"
AudioProvider[音频提供者]
TTSProvider[TTS提供者]
OtherProviders[其他提供者]
end
subgraph "数据层"
Files[本地文件数据]
end
subgraph "上下文管理"
Context[ConfigContext]
CtxVar[config_var]
end
TUI --> Config
TUI --> Logger
TUI --> AudioService
TUI --> TTSService
TUI --> OtherServices
Config --> Files
Config --> Context
AudioService --> AudioProvider
TTSService --> TTSProvider
OtherServices --> OtherProviders
Reactor --> Algorithms
Reactor --> Particles
Reactor --> Puzzles
Particles --> Files
Algorithms --> Files
```
### 目录结构
```
src/heurams/
├── __init__.py # 包入口点
├── context.py # 全局上下文、路径、配置上下文管理器
├── services/ # 核心服务
│ ├── config.py # 配置管理
│ ├── logger.py # 日志系统
│ ├── timer.py # 时间服务
│ ├── audio_service.py # 音频播放抽象
│ └── tts_service.py # 文本转语音抽象
├── kernel/ # 核心业务逻辑
│ ├── algorithms/ # 间隔重复算法 (FSRS, SM2)
│ ├── particles/ # 数据模型 (Atom, Electron, Nucleon, Orbital)
│ ├── puzzles/ # 谜题类型 (MCQ, cloze, recognition)
│ └── reactor/ # 调度和处理逻辑
├── providers/ # 外部服务提供者
│ ├── audio/ # 音频播放实现
│ ├── tts/ # 文本转语音实现
│ └── llm/ # LLM 集成
├── interface/ # Textual TUI 界面
│ ├── widgets/ # UI 组件
│ ├── screens/ # 应用屏幕
│ └── __main__.py # 应用入口点
└── default/ # 默认配置和数据模板
```
## 贡献
欢迎贡献!请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南.
## 许可证 ## 许可证
### 项目本身 本项目基于 AGPL-3.0 许可证开源. 详见 [LICENSE](LICENSE) 文件.
本项目基于 AGPL-3.0 许可证开源. 详见根目录下 [LICENSE](LICENSE) 文件.
### 第三方代码
项目在 `src/heurams/vendor/` 目录下嵌入或在其他位置间接使用了以下第三方代码(可能有修改):
#### py-fsrs (open-spaced-repetition)
- 上游版本: 6.3.1
- 引用方式: vendor
- 位置: `src/heurams/vendor/pyfsrs/`
- 原项目: [py-fsrs](https://github.com/open-spaced-repetition/py-fsrs)
- 原版权: Copyright (c) 2026 Open Spaced Repetition Contributors
- 原许可证: MIT License
#### SM.js (slaypni)
- 上游版本: commit `6e3bb4afaf484426deb4a9fa3bcffe42ac066b45` (2015年2月4日上游已停止维护)
- 引用方式: 将 coffeescript 重写为 python 并间接引用, 数学原理一致; 并对重写后代码进行逻辑, 性能与标准化 API 改进
- 位置: `src/heurams/kernel/algorithms/sm15m*.py`
- 原项目: [SM.js](https://github.com/slaypni/SM-15)
- 原版权: Copyright (c) 2014 Kazuaki Tanida
- 原许可证: MIT License
本项目受益于他们无私且优秀的工作.
+39
View File
@@ -0,0 +1,39 @@
# [调试] 将更改保存到文件
persist_to_file = 1
# [调试] 覆写时间, 设为 -1 以禁用
daystamp_override = -1
timestamp_override = -1
# [调试] 一键通过
quick_pass = 1
# 对于每个项目的默认新记忆原子数量
scheduled_num = 8
# UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒
timezone_offset = +28800 # 中国标准时间 (UTC+8)
[puzzles] # 谜题默认配置
[puzzles.mcq]
max_riddles_num = 2
[puzzles.cloze]
min_denominator = 3
[paths] # 相对于配置文件的 ".." (即工作目录) 而言 或绝对路径
nucleon_dir = "./data/nucleon"
electron_dir = "./data/electron"
orbital_dir = "./data/orbital"
cache_dir = "./data/cache"
template_dir = "./data/template"
[services] # 定义服务到提供者的映射
audio = "playsound" # 可选项: playsound(通用), termux(仅用于支持 Android Termux), mpg123(TODO)
tts = "edgetts" # 可选项: edgetts
llm = "openai" # 可选项: openai
[providers.llm.openai] # 与 OpenAI 相容的语言模型接口服务设置
url = ""
key = ""
-5
View File
@@ -1,5 +0,0 @@
_services_desc = '服务模块设置'
_providers_desc = '驱动模块设置'
_repo_desc = '单元集独立设置'
_interface_desc = '基本用户界面设置'
_global_desc = '底层设置'
-21
View File
@@ -1,21 +0,0 @@
zmq_debug = false
_zmq_debug_desc = "[调试] ZeroMQ 调试服务器, 这会在 zmq_debug_port 上打开一个服务器\n调试工具可远程在 HeurAMS 内执行任意 python 代码, 无必要请关闭"
zmq_debug_port = 5555
_zmq_debug_port_desc = "[调试] ZeroMQ 调试服务器端口"
enable_built_in_interface = true
_enable_built_in_interface_desc = "启用内置基本用户界面\n(当且仅当 HeurAMS 作为程序库时禁用, 以跳过用户界面逻辑)"
_paths_desc = "用户数据路径定义"
[paths]
data = "./data"
_data_desc = "用户数据根目录"
cache = "./data/cache"
_cache_desc = "缓存根目录\n(如音频缓存在 voice 子目录)"
config = "./data/config"
_config_desc = "配置文件根目录"
repo = "./data/repo"
_repo_desc = "记忆单元集根目录"
misc = "./data/misc"
_misc_desc = "扩展程序和 whisper 等模块的数据存储根目录"
_addons = "./data/addons"
__addons_desc = "扩展程序根目录"
-4
View File
@@ -1,4 +0,0 @@
_global_desc = "用户界面通用设置"
_widgets_desc = "组件设置"
_screens_desc = "界面设置"
_puzzles_desc = "谜题生成器设置"
-25
View File
@@ -1,25 +0,0 @@
show_header = true
_show_header_desc = "展示界面顶部的标题栏\n如果您想节省这一行空间, 可以禁用它"
clock_on_header = true
_clock_on_header_desc = "在界面顶部的标题栏显示时间"
change_window_title = true
_change_window_title_desc = "更改终端模拟器窗口的标题\n如果禁用了 header, 则建议启用"
persist_to_file = true
_persist_to_file_desc = "[调试] 将记忆更改保存到文件"
quick_pass = true
_quick_pass_desc = "[调试] 启用快速应答功能(跳过测验)"
auto_pass = false
_auto_pass_desc = "[调试] 自动通过测试模式"
scheduled_num = 420
_scheduled_num_desc = "默认记忆单元数量\n可被单元集设置覆盖"
refresh_on_resume = true
_refresh_on_resume_desc = "[调试] 每当 Screen 激活后都刷新状态"
algorithm = "SM-2"
_algorithm_desc = "默认记忆调度算法\n可被单元集设置覆盖"
[_algorithm_candidate]
NSP-0 = "筛选用非间隔重复调度器"
none = "不设置默认调度器"
SM-2 = "第二代 SuperMemo 简单间隔重复调度器\nWozniak 于 1987 年提出, Anki 的默认算法"
SM-15M = "类第15代 SuperMemo 复杂间隔重复调度器\n不稳定且逆向工程"
FSRS = "先进开放间隔重复调度器"
-2
View File
@@ -1,2 +0,0 @@
_cloze_desc = "填空题"
_mcq_desc = "选择题"
-2
View File
@@ -1,2 +0,0 @@
min_denominator = "2"
_min_denominator_desc = "设空比例系数的倒数"
-2
View File
@@ -1,2 +0,0 @@
max_riddles_num = "2"
_max_riddles_num_desc = "单次生成的最大谜题数量"
-1
View File
@@ -1 +0,0 @@
_recognition_desc = "用于 '辨识' 组件的设置"
@@ -1,2 +0,0 @@
autovoice = true
_autovoice_desc = "自动语音播放"
-1
View File
@@ -1 +0,0 @@
_tts_desc = '文本转语音驱动'
-1
View File
@@ -1 +0,0 @@
_edgetts_desc = "微软文本转语音驱动"
-25
View File
@@ -1,25 +0,0 @@
voice = "zh-CN-XiaoxiaoNeural"
_voice_desc = "音色"
[_voice_candidate]
zh-CN-XiaoxiaoNeural = "晓晓: 中文温柔女声"
zh-CN-XiaoyiNeural = "晓伊: 中文甜美女声"
zh-CN-XiaochenNeural = "晓辰: 中文知性女声"
zh-CN-XiaohanNeural = "晓涵: 中文优雅女声"
zh-CN-XiaomengNeural = "晓梦: 中文梦幻女声"
zh-CN-XiaomoNeural = "晓墨: 中文文艺女声"
zh-CN-XiaoqiuNeural = "晓秋: 中文成熟女声"
zh-CN-XiaoruiNeural = "晓睿: 中文智慧女声"
zh-CN-XiaoshuangNeural = "晓双: 中文活泼女声"
zh-CN-XiaoxuanNeural = "晓萱: 中文清新女声"
zh-CN-XiaoyanNeural = "晓颜: 中文柔美女声"
zh-CN-XiaoyouNeural = "晓悠: 中文悠扬女声"
zh-CN-XiaozhenNeural = "晓甄: 中文端庄女声"
zh-CN-YunxiNeural = "云希: 中文清朗男声"
zh-CN-YunyangNeural = "云扬: 中文阳光男声"
zh-CN-YunjianNeural = "云健: 中文稳重男声"
zh-CN-YunfengNeural = "云枫: 中文磁性男声"
zh-CN-YunhaoNeural = "云皓: 中文豪迈男声"
zh-CN-YunxiaNeural = "云夏: 中文热情男声"
zh-CN-YunyeNeural = "云野: 中文野性男声"
zh-CN-YunzeNeural = "云泽: 中文深沉男声"
-2
View File
@@ -1,2 +0,0 @@
_cngk-t_desc = "高考必备古诗文-测试"
_cngk_desc = "高考必备古诗文"
-11
View File
@@ -1,11 +0,0 @@
algorithm = "NSP-0"
_algorithm_desc = "记忆调度算法"
scheduled_num = 420
_scheduled_num_desc = "单次记忆单元数量"
[_algorithm_candidate]
NSP-0 = "筛选用非间隔重复调度器"
none = "不设置默认调度器"
SM-2 = "第二代 SuperMemo 简单间隔重复调度器"
SM-15M = "第15代 SuperMemo 复杂间隔重复调度器 (不稳定且逆向工程)"
FSRS = "先进开放间隔重复调度器"
-11
View File
@@ -1,11 +0,0 @@
algorithm = "SM-2"
_algorithm_desc = "记忆调度算法"
scheduled_num = 20
_scheduled_num_desc = "单次记忆单元数量"
[_algorithm_candidate]
NSP-0 = "筛选用非间隔重复调度器"
none = "不设置默认调度器"
SM-2 = "第二代 SuperMemo 简单间隔重复调度器"
SM-15M = "第15代 SuperMemo 复杂间隔重复调度器 (不稳定且逆向工程)"
FSRS = "先进开放间隔重复调度器"
-5
View File
@@ -1,5 +0,0 @@
_audio_desc = '音频服务'
_llm_desc = '语言模型服务'
_sync_desc = '数据同步服务'
_timer_desc = '时间服务'
_tts_desc = '文本转语音服务'
-9
View File
@@ -1,9 +0,0 @@
provider = "playsound"
_provider_desc = "音频驱动类型"
[_provider_candidate]
playsound = "python 跨平台音频系统"
termux = "Android Termux 音频系统"
mpg123 = "通用音频系统, 依赖系统 mpg123"
pulseaudio = "高级音频路由系统"
none = "不使用音频"
-6
View File
@@ -1,6 +0,0 @@
provider = "openai"
_provider_desc = "模型接口类型"
[_provider_candidate]
openai = "OpenAI 风格 API, 同时支持与其相容的模型服务 (如 deepseek)"
none = "不使用语言大模型"
-7
View File
@@ -1,7 +0,0 @@
provider = "webdav"
_provider_desc = "同步服务驱动类型"
[_provider_candidate]
webdav = "WebDAV 兼容网络文件系统 (包括 webdavs)"
official = "官方同步服务器"
none = "不使用同步服务器"
-6
View File
@@ -1,6 +0,0 @@
daystamp_override = -1
_daystamp_override_desc = "[调试] 覆写 UNIX 日时间戳, 单位为日\n(设为 -1 禁用)"
timestamp_override = -1
_timestamp_override_desc = "[调试] 覆写 UNIX 时间戳, 单位为秒\n(设为 -1 禁用)"
timezone_offset = 28800
_timezone_offset_desc = "时区偏移设置, 用于取消跨天时区误差, 单位为秒\n(如 28800 为 UTC+8.0, 中国标准时间)"
-7
View File
@@ -1,7 +0,0 @@
provider = "edgetts"
_provider_desc = "文本转语音驱动类型"
[_provider_candidate]
edgetts = "微软神经网络语音合成, 依赖微软网络服务"
espeak = "低保真度本地语音合成"
none = "不使用文本转语音"
-1
View File
@@ -1 +0,0 @@
[]
-1
View File
@@ -1 +0,0 @@
{}
-4
View File
@@ -1,4 +0,0 @@
title = "高考必背古诗文-筛选"
package = "cngk-t"
author = "__heurams__"
desc = "高考古诗文 60 篇"
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
schedule = ["quick_review", "recognition", "final_review"]
[routes]
quick_review = [["FillBlank", "1.0"], ["Recognition", "1.0"]]
recognition = [["FillBlank", "1.0"]]
final_review = [["FillBlank", "1.0"], ["Recognition", "1.0"]]
[annotation]
"quick_review" = "复习旧知"
"recognition" = "新知识"
"final_review" = "总复习"
-17
View File
@@ -1,17 +0,0 @@
[annotation]
note = "笔记"
keyword_note = "关键词翻译"
translation = "语句翻译"
delimiter = "分隔符"
content = "内容"
tts_text = "文本转语音文本"
[common]
delimiter = "/"
tts_text = "eval:payload['content'].replace('/', '')"
[common.puzzles] # 谜题定义
# 我们称 "Recognition" 为 recognition 谜题的 alia
"Recognition" = { __origin__ = "recognition", __hint__ = "", primary = "eval:payload['content']", secondary = ["eval:payload['keyword_note']", "eval:payload['note']"], top_dim = ["eval:payload['translation']"] }
"SelectMeaning" = { __origin__ = "mcq", __hint__ = "eval:payload['content']", primary = "eval:payload['content']", mapping = "eval:payload['keyword_note']", jammer = "eval:list(payload['keyword_note'].values())", max_riddles_num = "eval:default['mcq']['max_riddles_num']", prefix = "选择正确项: " }
"FillBlank" = { __origin__ = "cloze", __hint__ = "", text = "eval:payload['content']", delimiter = "eval:nucleon['delimiter']", min_denominator = "eval:default['cloze']['min_denominator']"}
+23
View File
@@ -0,0 +1,23 @@
# Nucleon 是 HeurAMS 软件项目使用的基于 TOML 的专有源文件格式, 版本 5
# 建议使用的 MIME 类型: application/vnd.xyz.imwangzhiyu.heurams-nucleon.v5+toml
[__metadata__]
[__metadata__.attribution] # 元信息
desc = "带有宏支持的空白模板"
[__metadata__.annotation] # 键批注
[__metadata__.formation] # 文件配置
#delimiter = "/"
#tts_text = "eval:nucleon['content'].replace('/', '')"
[__metadata__.orbital.puzzles] # 谜题定义
# 我们称 "Recognition" 为 recognition 谜题的 alia
#"Recognition" = { __origin__ = "recognition", __hint__ = "", primary = "eval:nucleon['content']", secondary = ["eval:nucleon['keyword_note']", "eval:nucleon['note']"], top_dim = ["eval:nucleon['translation']"] }
#"SelectMeaning" = { __origin__ = "mcq", __hint__ = "eval:nucleon['content']", mapping = "eval:nucleon['keyword_note']", jammer = "eval:nucleon['keyword_note']", max_riddles_num = "eval:default['mcq']['max_riddles_num']", prefix = "选择正确项: " }
#"FillBlank" = { __origin__ = "cloze", __hint__ = "", text = "eval:nucleon['content']", delimiter = "eval:metadata['formation']['delimiter']", min_denominator = "eval:default['cloze']['min_denominator']"}
[__metadata__.orbital.schedule] # 内置的推荐学习方案
#quick_review = [["FillBlank", "1.0"], ["SelectMeaning", "0.5"], ["recognition", "1.0"]]
#recognition = [["Recognition", "1.0"]]
#final_review = [["FillBlank", "0.7"], ["SelectMeaning", "0.7"], ["recognition", "1.0"]]
+18 -67
View File
@@ -1,76 +1,27 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project] [project]
name = "heurams" name = "heurams"
version = "0.5.0" version = "0.4.0"
authors = [{ name = "pluvium27", email = "pluvium27@outlook.com" }] description = "Heuristic Assisted Memory Scheduler"
description = "Heuristic Auxiliary Memory Scheduler" license = {file = "LICENSE"}
readme = "README.md"
requires-python = ">=3.12"
classifiers = [ classifiers = [
"License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Topic :: Education",
"Intended Audience :: Education",
] ]
license = "AGPL-3.0-or-later" keywords = ["spaced-repetition", "memory", "learning", "tui", "textual", "flashcards", "education"]
license-files = ["LICENSE"] dependencies = [
"bidict==0.23.1",
dependencies = [ # 这些依赖只能驱动 kernel 程序库
"tabulate>=0.10.0",
"toml>=0.10.2",
"transitions>=0.9.3",
]
[project.optional-dependencies]
interface = [ # 基本用户界面依赖
"textual>=8.2.3",
"psutil>=7.2.2",
]
algo-fsrs = [
"fsrs>=6.3.1", # FSRS 算法底层依赖
]
tts-edgetts = [
"edge-tts>=7.2.8", # 微软 TTS
]
misc-jieba = [
"jieba>=0.42.1", # 用于中文智能分词
]
llm-openai = [
"openai>=2.32.0",
]
audio-playsound = [
"playsound==1.2.2", "playsound==1.2.2",
"pygobject>=3.56.2", # playsound 依赖它 "textual==5.3.0",
] "toml==0.10.2",
dev = [ # 调试所需依赖
"zmq>=0.0.0", # 用于 ZMQ 远程调试服务器, 在 linux 上建议先安装 libzmq
"pytest>=8.0.0", # 用于普通测试
"pytest-cov>=6.0.0",
]
all = [
"heurams[algo-fsrs]",
"heurams[tts-edgetts]",
"heurams[misc-jieba]",
"heurams[llm-openai]",
"heurams[audio-playsound]",
"heurams[dev]",
] ]
readme = "README.md"
[project.urls] [tool.setuptools.packages.find]
Homepage = "https://ams.pluv.top" where = ["src"]
Issues = "https://github.com/heurams/heurams/issues"
[project.scripts]
heurams = "heurams.__main__:main"
tui = "heurams.interface.__main__:main"
heurams-tui = "heurams.interface.__main__:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
pythonpath = ["src"]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
]
[build-system]
requires = ["uv_build>=0.7.19"]
build-backend = "uv_build"
+2 -6
View File
@@ -1,8 +1,4 @@
edge-tts==7.0.2 bidict==0.23.1
jieba==0.42.1
openai==1.0.0
playsound==1.2.2 playsound==1.2.2
tabulate>=0.9.0 textual==5.3.0
textual==7.0.0
toml==0.10.2 toml==0.10.2
transitions==0.9.3
+3 -19
View File
@@ -1,23 +1,7 @@
import heurams.services.version as ver prompt = """HeurAMS 已经被成功地安装在系统中.
但 HeurAMS 被设计为一个带有辅助记忆调度器功能的软件包, 无法直接被执行, 但可被其他 Python 程序调用.
若您想启动内置的基本用户界面,
# __main__.py
def main():
prompt = f"""HeurAMS {ver.ver} 已经被成功地安装在系统中.
HeurAMS 被设计为一个带有辅助记忆调度器功能的软件包, 无法直接被执行, 但可被其他 Python 程序调用.
若您想启动内置的基本用户界面:
请运行 python -m heurams.interface, 请运行 python -m heurams.interface,
或者 python -m heurams.interface.__main__ 或者 python -m heurams.interface.__main__
python 代指您使用的解释器, 在某些发行版中可能是 python3, 而 python 命令被指向了 python2.
尽管项目保留了 requirements.txt, 我们仍不推荐使用系统 python 和原始 venv 进行开发.
项目的推荐开发环境工具是 uv.
如果你的环境已经安装了 uv:
先运行 uv sync 同步环境, 此命令只需要执行一遍, uv 会自动处理依赖.
然后通过运行 uv run tui 启动内置基本用户界面.
此时您的解释器在项目目录里的 .venv/bin 中, 使用 IDE 开发前, 务必切换解释器!
注意: 一个常见的误区是, 执行 interface 下的 __main__.py 运行基本用户界面, 这会导致 Python 上下文环境异常, 请不要这样做.""" 注意: 一个常见的误区是, 执行 interface 下的 __main__.py 运行基本用户界面, 这会导致 Python 上下文环境异常, 请不要这样做."""
print(prompt) print(prompt)
if __name__ == "__main__":
main()
+24 -19
View File
@@ -6,35 +6,40 @@
import pathlib import pathlib
from contextvars import ContextVar from contextvars import ContextVar
from heurams.services.config import ConfigDict from heurams.services.config import ConfigFile
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
# 默认数据目录, 以包目录下的 data 为准 # 默认配置文件路径规定: 以包目录为准
# 用户数据目录, 以运行目录下的 data 为准 # 用户配置文件路径规定: 以运行目录为准
# 数据文件路径规定: 以运行目录为准
rootdir: pathlib.Path = pathlib.Path(__file__).parent
"""包目录路径, 也就是 heurams 目录."""
workdir = pathlib.Path.cwd()
"""工作目录路径."""
rootdir = pathlib.Path(__file__).parent
print(f"rootdir: {rootdir}")
logger = get_logger(__name__) logger = get_logger(__name__)
logger.debug(f"目录: {rootdir}") logger.debug(f"项目根目录: {rootdir}")
workdir = pathlib.Path.cwd()
print(f"workdir: {workdir}")
logger.debug(f"工作目录: {workdir}") logger.debug(f"工作目录: {workdir}")
config_var: ContextVar[ConfigFile] = ContextVar(
(workdir / "data" / "config").mkdir(parents=True, exist_ok=True) "config_var", default=ConfigFile(rootdir / "default" / "config" / "config.toml")
config_var: ContextVar[ConfigDict].get = ContextVar(
"config_var",
default=ConfigDict(workdir / "data" / "config"),
) )
"""配置对象的全局引用对象.""" try:
config_var: ContextVar[ConfigFile] = ContextVar(
"config_var", default=ConfigFile(workdir / "config" / "config.toml")
) # 配置文件
print("已加载自定义用户配置")
logger.info("已加载自定义用户配置, 路径: %s", workdir / "config" / "config.toml")
except Exception as e:
print("未能加载自定义用户配置")
logger.warning("未能加载自定义用户配置, 错误: %s", e)
# runtime_var: ContextVar = ContextVar('runtime_var', default=dict()) # 运行时共享数据
class ConfigContext: class ConfigContext:
""" """
功能完备的上下文管理器 功能完备的上下文管理器
用于临时切换配置引用对象的作用域, 支持嵌套使用 用于临时切换配置的作用域, 支持嵌套使用
Example: Example:
>>> with ConfigContext(test_config): >>> with ConfigContext(test_config):
@@ -42,7 +47,7 @@ class ConfigContext:
>>> get_daystamp() # 恢复原配置 >>> get_daystamp() # 恢复原配置
""" """
def __init__(self, config_provider: ConfigDict): def __init__(self, config_provider: ConfigFile):
self.config_provider = config_provider self.config_provider = config_provider
self._token = None self._token = None
+39
View File
@@ -0,0 +1,39 @@
# [调试] 将更改保存到文件
persist_to_file = 1
# [调试] 覆写时间, 设为 -1 以禁用
daystamp_override = -1
timestamp_override = -1
# [调试] 一键通过
quick_pass = 1
# 对于每个项目的默认新记忆原子数量
scheduled_num = 8
# UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒
timezone_offset = +28800 # 中国标准时间 (UTC+8)
[puzzles] # 谜题默认配置
[puzzles.mcq]
max_riddles_num = 2
[puzzles.cloze]
min_denominator = 3
[paths] # 相对于配置文件的 ".." (即工作目录) 而言 或绝对路径
nucleon_dir = "./data/nucleon"
electron_dir = "./data/electron"
orbital_dir = "./data/orbital"
cache_dir = "./data/cache"
template_dir = "./data/template"
[services] # 定义服务到提供者的映射
audio = "playsound" # 可选项: playsound(通用), termux(仅用于支持 Android Termux), mpg123(TODO)
tts = "edgetts" # 可选项: edgetts
llm = "openai" # 可选项: openai
[providers.llm.openai] # 与 OpenAI 相容的语言模型接口服务设置
url = ""
key = ""
-1
View File
@@ -1,3 +1,2 @@
# Interface - 用户界面 # Interface - 用户界面
与界面系统**强绑定**的相关代码文件, "界面系统" 在此处是基本界面实现相关的 Textual 框架 与界面系统**强绑定**的相关代码文件, "界面系统" 在此处是基本界面实现相关的 Textual 框架
-91
View File
@@ -1,91 +0,0 @@
from time import sleep, perf_counter
# import gc
# gc.set_threshold(100, 1, 1)
print("欢迎使用基本用户界面!")
print("加载配置与上下文... ", 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("加载用户界面框架... ", 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("加载用户界面布局... ", 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 . import shim
_end = perf_counter()
print(f"已完成! (耗时: {round(1000 * (_end - _start))}ms)")
print(f"组件目录: {rootdir}")
print(f"工作目录: {workdir}")
_end_all = perf_counter()
print(f"前置工作共计耗时: {round(1000 * (_end_all - _start_all))}ms")
class HeurAMSApp(App):
TITLE = "潜进"
CSS_PATH = rootdir / "interface" / "css" / "main.tcss"
SUB_TITLE = "启发式辅助记忆调度器"
BINDINGS = [
("q", "go_back", "退出"),
("d", "toggle_dark", "主题"),
("n", "app.push_screen('navigator')", "导航"),
("s", "app.push_screen('setting')", "设置"),
("z", "app.push_screen('about')", "关于"),
]
SCREENS = {
"dashboard": DashboardScreen,
"precache_all": PrecachingScreen,
"synctool": SyncScreen,
"about": AboutScreen,
"navigator": NavigatorScreen,
"setting": SettingScreen,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def on_mount(self) -> None:
self.push_screen("dashboard")
def action_go_back(self) -> None:
self.exit() # go_back 在最顶层是退出, Screen 会再次定义为返回, 键位都是 q, 免得不一样
def action_do_nothing(
self,
) -> None: # 用来给没使用/禁用的快捷键占位, 因为 Binding 删除不了
pass
# 移除烦人的 "rich traceback"
# Textual 官方不会管这破事, 写 Rich 写入脑了导致的
# 不知道哪来的自信改标准库的 traceback
# https://github.com/Textualize/textual/discussions/6255
# NOTE: 进行 textual 版本升级时, 确保查看过上游代码, 尤其是 App 的 _exception
# 如果行为变了就把下面的删了 (虽然有 fallback)
def _fatal_error(self):
if hasattr(self, "_exception"):
self._close_messages_no_wait()
raise self._exception
super()._fatal_error() # fallback
def panic(self, *args):
if hasattr("_exception"):
self._close_messages_no_wait()
raise self._exception
super().panic(*args) # ditto
+63 -44
View File
@@ -1,20 +1,53 @@
from heurams.interface import * from textual.app import App
from heurams.context import config_var from textual.widgets import Button
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
import threading
import zmq from .screens.about import AboutScreen
import pickle from .screens.dashboard import DashboardScreen
from .screens.nucreator import NucleonCreatorScreen
from .screens.precache import PrecachingScreen
logger = get_logger(__name__) logger = get_logger(__name__)
class HeurAMSApp(App):
TITLE = "潜进"
CSS_PATH = "css/main.tcss"
SUB_TITLE = "启发式辅助记忆调度器"
BINDINGS = [
("q", "quit", "退出"),
("d", "toggle_dark", "切换色调"),
("1", "app.push_screen('dashboard')", "仪表盘"),
("2", "app.push_screen('precache_all')", "缓存管理器"),
("3", "app.push_screen('nucleon_creator')", "创建新单元"),
("0", "app.push_screen('about')", "版本信息"),
]
SCREENS = {
"dashboard": DashboardScreen,
"nucleon_creator": NucleonCreatorScreen,
"precache_all": PrecachingScreen,
"about": AboutScreen,
}
def on_mount(self) -> None:
self.push_screen("dashboard")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.exit(event.button.id)
def action_do_nothing(self):
print("DO NOTHING")
self.refresh()
def environment_check(): def environment_check():
from pathlib import Path from pathlib import Path
logger.debug("检查环境路径") logger.debug("检查环境路径")
subdir = ["cache/voice", "repo", "global", "config"]
for i in subdir: for i in config_var.get()["paths"].values():
i = Path(config_var.get()["global"]["paths"]["data"]) / i i = Path(i)
if not i.exists(): if not i.exists():
logger.info("创建目录: %s", i) logger.info("创建目录: %s", i)
print(f"创建 {i}") print(f"创建 {i}")
@@ -25,46 +58,32 @@ def environment_check():
logger.debug("环境检查完成") logger.debug("环境检查完成")
def start_debug_server(app): def is_subdir(parent, child):
logger = get_logger("zmq_debug")
context = zmq.Context()
socket = context.socket(zmq.REP)
port = config_var.get()["global"].get("zmq_debug_port", 5555)
socket.bind(f"tcp://*:{port}")
logger.info(f"ZMQ Debug server started on port {port}")
first = 1
while True:
msg = socket.recv()
code = pickle.loads(msg)
namespace = {"app": app, "logger": logger, "config_var": config_var}
if first:
app.title += " [调试已连接]"
first = 0
try: try:
# 先尝试 eval child.relative_to(parent)
result = eval(code, namespace) logger.debug("is_subdir: %s%s 的子目录", child, parent)
socket.send(pickle.dumps(f"成功: {result}")) return 1
except SyntaxError: except:
# 再尝试 exec logger.debug("is_subdir: %s 不是 %s 的子目录", child, parent)
try: return 0
exec(code, namespace)
socket.send(pickle.dumps(f"成功: 执行完成"))
except Exception as e:
socket.send(pickle.dumps(f"错误: {e}"))
except Exception as e:
socket.send(pickle.dumps(f"错误: {e}"))
def main(): import os
from pathlib import Path
# 开发模式
from heurams.context import config_var, rootdir, workdir
if is_subdir(Path(rootdir), Path(os.getcwd())):
os.chdir(Path(rootdir) / ".." / "..")
print(f'转入开发数据目录: {Path(rootdir)/".."/".."}')
environment_check() environment_check()
app = HeurAMSApp() app = HeurAMSApp()
if config_var.get()["global"].get("zmq_debug", False):
threading.Thread(target=start_debug_server, args=(app,), daemon=True).start()
app.run(inline=False)
if __name__ == "__main__": if __name__ == "__main__":
main() app.run()
def main():
app.run()
-15
View File
@@ -1,15 +0,0 @@
NavigatorScreen {
align: center middle;
}
#dialog {
grid-size: 2;
grid-gutter: 1 1;
grid-rows: 1fr 3;
padding: 0 1;
width: 46;
height: 12;
border: thick $background 80%;
background: $surface;
}
@@ -1,23 +0,0 @@
.repo-list {
}
#header {
height: 3;
}
#analysis {
margin: 0;
padding: 0;
}
.repo-list-item {
layout: grid;
grid-size: 1;
height: 3;
}
.repo-list-item-shortcut {
dock: right;
offset: -5% 0
}
@@ -1,4 +0,0 @@
#puzzle_container > * {
height: auto;
width: auto;
}
@@ -1,24 +0,0 @@
#operations {
height: auto;
}
.btn {
margin: 0 1 0 0;
padding: 0 1 0 1;
}
#main_container {
}
#previewer_container {
}
.unit-statline {
}
#title {
padding: 0;
}
@@ -1,23 +0,0 @@
.foot {
align-vertical: bottom;
}
.setting-switch {
dock: right;
}
Select {
width: 55%;
dock: right;
}
Input {
width: 55%;
dock: right;
}
.setting-item {
width: 100%;
height: 4;
padding: 0 0 1 0;
}
+39 -134
View File
@@ -1,105 +1,79 @@
"""关于界面""" #!/usr/bin/env python3
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import ScrollableContainer from textual.containers import ScrollableContainer
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Label, Markdown from textual.widgets import Button, Footer, Header, Label, Markdown, Static
from textual import events, on
import heurams.services.version as version import heurams.services.version as version
from heurams.context import * from heurams.context import *
import platform
import shutil
import psutil
import os
import sys
class AboutScreen(Screen): class AboutScreen(Screen):
BINDINGS = [
("q", "go_back", "返回"),
("z", "go_back", "关于"),
]
SUB_TITLE = "关于"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True)
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="about_container"): with ScrollableContainer(id="about_container"):
yield Label("[b]关于与版本信息[/b]") yield Label("[b]关于与版本信息[/b]")
# 获取系统信息
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()
memory_info = self._get_memory_info()
about_text = f""" about_text = f"""
# 关于 "潜进" # 关于 "潜进"
主程序库版本: `{version.ver}-python` 版本 {version.ver} {version.stage.capitalize()}
用户界面分支: `Textual TUI (基本用户界面)`
用户界面版本: `{version.ver}`
API 版本代号: `{version.codename.capitalize()}`
一个基于启发式算法与认知科学理论的辅助记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划. 开发代号: {version.codename.capitalize()}
一个开放, 优雅, 易于扩展的间隔重复调度器实验平台, 旨在帮助研究者更高效地进行前沿记忆算法的研究.
以 AGPL-3.0 开放源代码, 这直接意味着任何个体直接基于此代码对外或内部提供的应用和服务, 无论本地或网络, 必须向所有用户公开完整修改后的源代码, 且继续沿用 AGPL-3.0 协议. 一个基于启发式算法的开放源代码记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划.
您正使用的 TUI 用户界面是 python 版本程序库自带的基本用户界面, 以作为第一个全功能前端实现与程序库测试套件, 位于程序库根目录中的 interface 文件夹. 以 AGPL-3.0 开放源代码
您可在项目主页 https://ams.pluv27.top 获取用户指南, 开发文档与软件更新. 开发人员:
如果您觉得这个软件有用, 可以考虑参与贡献, 或在它的源代码仓库给它添加一个星标 :) - Wang Zhiyu([@pluvium27](https://github.com/pluvium27)): 项目作者
您的慷慨支持, 我们必当涌泉相报. 特别感谢:
开发人员列表: - [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SuperMemo-2 算法
- [Thoughts Memo](https://www.zhihu.com/people/L.M.Sherlock): 文献参考
- Wang Zhiyu([@pluvium27](https://github.com/pluvium27)): 发起项目与主要维护者 # 参与贡献
特别感谢以下人士, 他们的算法与理论构成了此软件现有算法的基石: 我们是一个年轻且包容的社区, 由技术人员, 设计师, 文书工作者, 以及创意人员共同构成,
- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2 算法与 SM-15 算法理论 通过我们协力开发的软件为所有人谋取福祉.
- [Kazuaki Tanida](https://github.com/slaypni): SM-15 算法的 CoffeeScript 实现
- [Thoughts Memo](https://www.zhihu.com/people/L.M.Sherlock): 中文文献参考
上述工作不可避免地让我们确立了下列价值观 (取自 KDE 宣言):
# 运行环境信息 - 开放治理 确保更多人能参与我们的领导和决策进程;
Python 解释器版本: {python_version} - 自由软件 确保我们的工作成果随时能为所有人所用;
Python 解释器路径: {sys.executable}
Textual 框架版本: {textual_version}
终端模拟器: {terminal_info}
操作系统版本: {os_version}
存储余量: {disk_usage}
内存大小: {memory_info}
报告问题时, 请复制这些信息到问题描述, 并上传软件日志 `heurams.log` 作为附件, 以协助开发者定位错误 - 多样包容 确保所有人都能加入社区并参加工作;
- 创新精神 确保新思路能不断涌现并服务于所有人;
- 共同产权 确保我们能团结一致;
- 迎合用户 确保我们的成果对所有人有用.
综上所述, 在为我们共同目标奋斗的过程中, 我们认为上述价值观反映了我们社区的本质, 是我们始终如一地保持初心的关键所在.
这是一项立足于协作精神的事业, 它的运作和产出不受任何单一个人或者机构的操纵.
我们的共同目标是为人人带来高品质的辅助记忆 & 学习软件.
不管您来自何方, 我们都欢迎您加入社区并做出贡献.
""" """
# """
# 学术数据
# "潜进" 的用户数据可用于科学方面的研究, 我们将在未来版本添加学术数据的收集和展示平台
# """
yield Markdown(about_text, classes="about-markdown") yield Markdown(about_text, classes="about-markdown")
yield Button( yield Button(
"返回主界面", "返回主界面",
id="back_button", id="back_button",
variant="primary", variant="primary",
flat=True,
classes="back-button", classes="back-button",
) )
yield Footer() yield Footer()
@@ -111,72 +85,3 @@ Textual 框架版本: {textual_version}
event.stop() event.stop()
if event.button.id == "back_button": if event.button.id == "back_button":
self.action_go_back() self.action_go_back()
def _get_textual_version(self) -> str:
"""获取 Textual 框架版本"""
try:
import textual
return textual.__version__
except (ImportError, AttributeError):
return "未知"
def _get_terminal_info(self) -> str:
"""获取终端模拟器信息"""
terminal = shutil.which("terminal")
if terminal:
return terminal
# 尝试从环境变量获取
terminal_env = os.environ.get("TERM_PROGRAM") or os.environ.get("TERM")
return terminal_env or "未知"
def _get_python_version(self) -> str:
"""获取 Python 解释器版本"""
return platform.python_version()
def _get_os_version(self) -> str:
"""获取操作系统版本"""
try:
if platform.system() == "Darwin":
# macOS
import subprocess
result = subprocess.run(
["sw_vers", "-productVersion"], capture_output=True, text=True
)
return f"macOS {result.stdout.strip()}"
elif platform.system() == "Windows":
# Windows
return f"Windows {platform.release()}"
elif platform.system() == "Linux":
# Linux - 尝试获取发行版信息
try:
import distro
return f"{distro.name()} {distro.version()}"
except (ImportError, AttributeError):
return platform.platform()
else:
return platform.platform()
except Exception:
return platform.platform()
def _get_disk_usage(self) -> str:
"""获取磁盘使用情况"""
try:
usage = psutil.disk_usage("/")
free_gb = usage.free / (1024**3)
total_gb = usage.total / (1024**3)
percent_free = (free_gb / total_gb) * 100
return f"{free_gb:.1f} GB ({percent_free:.1f}%)"
except Exception:
return "未知"
def _get_memory_info(self) -> str:
"""获取内存信息"""
try:
memory = psutil.virtual_memory()
total_gb = memory.total / (1024**3)
return f"{total_gb:.1f} GB"
except Exception:
return "未知"
+101 -172
View File
@@ -1,218 +1,147 @@
"""仪表盘界面""" #!/usr/bin/env python3
import pathlib
from functools import reduce
from pathlib import Path
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import ScrollableContainer, Horizontal, Vertical from textual.containers import ScrollableContainer
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Label, ListItem, ListView, Static from textual.widgets import (Button, Footer, Header, Label, ListItem, ListView,
from textual import events, on Static)
from textual.reactive import reactive
import heurams.kernel.particles as pt
import heurams.services.timer as timer import heurams.services.timer as timer
import heurams.services.version as version import heurams.services.version as version
from heurams.context import * from heurams.context import *
from heurams.kernel.particles import * from heurams.kernel.particles import *
from heurams.kernel.repolib import *
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .navigator import NavigatorScreen from .about import AboutScreen
from .preparation import PreparationScreen from .preparation import PreparationScreen
logger = get_logger(__name__) logger = get_logger(__name__)
class DashboardScreen(Screen): class DashboardScreen(Screen):
"""主仪表盘屏幕"""
SUB_TITLE = "仪表盘" SUB_TITLE = "仪表盘"
BINDINGS = [
("q", "go_back", "返回"),
]
CSS_PATH = rootdir / "interface" / "css" / "screens" / "dashboard.tcss"
repolink = reactive({})
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self._load_data()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""组合界面组件""" yield Header(show_clock=True)
if config_var.get()["interface"]["global"]["show_header"]: yield ScrollableContainer(
yield Header( Label(f'欢迎使用 "潜进" 启发式辅助记忆调度器', classes="title-label"),
show_clock=config_var.get()["interface"]["global"]["clock_on_header"] Label(f"当前 UNIX 日时间戳: {timer.get_daystamp()}"),
Label(f'时区修正: UTC+{config_var.get()["timezone_offset"] / 3600}'),
Label("选择待学习或待修改的记忆单元集:", classes="title-label"),
ListView(id="union-list", classes="union-list-view"),
Label(
f'"潜进" 启发式辅助记忆调度器 | 版本 {version.ver} {version.codename.capitalize()} 2025'
),
) )
with ScrollableContainer():
yield Horizontal( # 顶部的状态
Vertical(
Label(f"当前日时间戳: {timer.get_daystamp()}"),
Label(
f"应用时区修正: UTC+{str(config_var.get()['services']['timer']['timezone_offset'] / 3600).removesuffix('.0')}"
),
Label(
f"默认算法设置: {config_var.get()['interface']['global']['algorithm']}",
),
classes="left",
),
Vertical(
Label(f"已加载 {len(self.repos)} 个单元集"),
Label(
f"共计 {reduce(lambda x, y: x + y, map(lambda x: x.progress['total'], self.repos))} 个单元"
),
Label(
f"已激活 {reduce(lambda x, y: x + y, map(lambda x: x.progress['touched'], self.repos))} 个单元"
),
Label(f""),
classes="right",
),
id="header",
)
yield ListView(id="repo_list", classes="repo-list") # 单元集选择
from heurams.services.attic import Attic
a = Attic("ana", {"totaltime": 0, "openpuzzles": 0, "puzzles_err": 0})
yield Label(f"版本 {version.ver} {version.stage.capitalize()}") # 版本信息
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)) + ' 个每秒'}",
id="analysis",
) # 版本信息
yield Footer() yield Footer()
@on(events.ScreenResume) def item_desc_generator(self, filename) -> dict:
def post_active(self, event): """简单分析以生成项目项显示文本
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}") Returns:
# https://github.com/Textualize/textual/discussions/4268 dict: 以数字为列表, 分别呈现单行字符串
# self.refresh(recompose=True) 此函数有问题且官方不管 而且性能低 """
res = dict()
filestem = pathlib.Path(filename).stem
res[0] = f"{filename}\0"
import heurams.kernel.particles as pt
from heurams.kernel.particles.loader import load_electron
def _load_data(self): electron_file_path = pathlib.Path(config_var.get()["paths"]["electron_dir"]) / (
repo_dirs = Repo.probe_valid_repos_in_dir( filestem + ".json"
Path(config_var.get()["global"]["paths"]["repo"])
) )
self.repos = list(map(Repo.from_repodir, repo_dirs))
for repo in self.repos:
self._analyse_repo(repo)
def _analyse_repo(self, repo: Repo): logger.debug(f"电子文件路径: {electron_file_path}")
# need_review: 需要/不需要学习
# nearest_review_time: 最近下次学习时间 if electron_file_path.exists(): # 未找到则创建电子文件 (json)
# progress: 进度 pass
## initial_time: 起始时间 else:
# package: 包名 electron_file_path.touch()
# prompt: 最终呈现信息 with open(electron_file_path, "w") as f:
repo.package = repo.manifest["package"] f.write("{}")
repo.nearest_review_time = float("inf") electron_dict = load_electron(path=electron_file_path) # TODO: 取消硬编码扩展名
repo.progress = { logger.debug(electron_dict)
"total": repo.data_length, is_due = 0
"touched": 0, is_activated = 0
"have_activated_ever": False, nextdate = 0x3F3F3F3F
} for i in electron_dict.values():
repo.preview = { i: pt.Electron
"review": 0, logger.debug(i, i.is_due())
"new": repo.config[ if i.is_due():
"scheduled_num" is_due = 1
], # TODO: 考虑之后在这里加点运算避免 SM-2 积压, 但现在需要的是直观! if i.is_activated():
} is_activated = 1
initial_time = float("inf") nextdate = min(nextdate, i.nextdate())
for i in range( res[1] = f"下一次复习: {nextdate}\n"
repo.data_length res[1] += f"{is_due if "需要复习" else "当前无需复习"}"
): # TODO: 增加异步性能优化, 但是学习数据属实规模小... if not is_activated:
e = pt.Electron.from_data( res[1] = " 尚未激活"
electronic_data=repo.electronic_data_lict[i], return res
algo_name=repo.config["algorithm"],
)
# n = pt.Nucleon.from_data(repo.nucleonic_data_lict[i])
if e.is_activated():
repo.progress["have_activated_ever"] = True # 被激活过~
repo.progress["touched"] += 1
repo.nearest_review_time = min(repo.nearest_review_time, e.nextdate())
if timer.get_daystamp() >= e.nextdate():
repo.preview["review"] += 1
# initial_time = min(initial_time, e.)
repo.need_review = timer.get_daystamp() >= repo.nearest_review_time
repo.prompt = 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]"""
def on_mount(self) -> None: def on_mount(self) -> None:
"""挂载组件时初始化""" union_list_widget = self.query_one("#union-list", ListView)
repo_list_widget = self.query_one("#repo_list", ListView)
# 按下次复习时间排序 probe = probe_all(0)
repodirs = sorted(
self.repos, if len(probe["nucleon"]):
key=lambda r: r.nearest_review_time, for file in probe["nucleon"]:
reverse=True, # 紧张的先复习 text = self.item_desc_generator(file)
union_list_widget.append(
ListItem(
Label(text[0] + "\n" + text[1]),
) )
)
# 填充列表 else:
if not repodirs: union_list_widget.append(
repo_list_widget.append(
ListItem( ListItem(
Static( Static(
f"{config_var.get()['global']['paths']['repo']} 中未找到任何仓库.\n" "./nucleon/ 中未找到任何内容源数据文件.\n请放置文件后重启应用.\n或者新建空的单元集."
"请导入仓库后重启应用, 或者新建空的仓库."
),
id="not-found",
) )
) )
repo_list_widget.disabled = True
return
for r in self.repos:
self.repolink[str(r.manifest["package"])] = r # 用于规避 ctype id 对象还原
# NOTE: 上一行不要使用 id(), id 可能被重用!
list_item = ListItem(
*[Label(line) for line in r.prompt.splitlines()],
Button(
f"开始学习",
flat=True,
variant="primary",
id=f"slaunch_repo_{r.manifest['package']}",
classes="repo-list-item-shortcut",
),
classes="repo-list-item",
id=f"launch_repo_{r.manifest['package']}",
) )
repo_list_widget.append(list_item) union_list_widget.disabled = True
def on_list_view_selected(self, event) -> None: def on_list_view_selected(self, event) -> None:
"""处理列表项选择事件"""
if not isinstance(event.item, ListItem): if not isinstance(event.item, ListItem):
return return
if "not-found" == event.item.id: selected_label = event.item.query_one(Label)
if "未找到任何 .toml 文件" in str(selected_label.renderable): # type: ignore
return return
# 还原对象 selected_filename = pathlib.Path(
selected_repo = self.repolink[event.item.id.removeprefix("launch_repo_")] str(selected_label.renderable)
.partition("\0")[0] # 文件名末尾截断, 保留文件名
.replace("*", "")
) # 去除markdown加粗
# 跳转到准备屏幕 nucleon_file_path = (
self.app.push_screen(PreparationScreen(selected_repo)) pathlib.Path(config_var.get()["paths"]["nucleon_dir"]) / selected_filename
)
electron_file_path = pathlib.Path(config_var.get()["paths"]["electron_dir"]) / (
str(selected_filename.stem) + ".json"
)
self.app.push_screen(PreparationScreen(nucleon_file_path, electron_file_path))
def on_button_pressed(self, event) -> None:
if event.button.id == "new_nucleon_button":
# 切换到创建单元
from .nucreator import NucleonCreatorScreen
newscr = NucleonCreatorScreen()
self.app.push_screen(newscr)
elif event.button.id == "precache_all_button":
# 切换到缓存管理器
from .precache import PrecachingScreen
precache_screen = PrecachingScreen()
self.app.push_screen(precache_screen)
elif event.button.id == "about_button":
from .about import AboutScreen
about_screen = AboutScreen()
self.app.push_screen(about_screen)
def action_quit_app(self) -> None: def action_quit_app(self) -> None:
"""退出应用程序"""
self.app.exit() self.app.exit()
def action_open_navigator(self) -> None:
"""打开导航器"""
self.app.push_screen(NavigatorScreen())
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
launch(repo=self.repolink[event.button.id.removeprefix("slaunch_repo_")], app=self.app, scheduled_num=-1) # type: ignore
# TODO: 这样启动的记忆实例的状态机无法绑定到 PreparationScreen 中
-217
View File
@@ -1,217 +0,0 @@
"""收藏夹管理器界面"""
import base64
from pathlib import Path
from typing import List, Optional
from textual import events, on
from textual.app import ComposeResult
from textual.containers import ScrollableContainer
from textual.screen import Screen
from textual.widgets import (
Button,
Footer,
Header,
Label,
ListItem,
ListView,
Markdown,
Static,
)
from textual import events, on
from heurams.context import config_var
from heurams.kernel.repolib import Repo
from heurams.services.favorite_service import FavoriteItem, favorite_manager
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class FavoriteManagerScreen(Screen):
"""收藏夹管理器屏幕"""
SUB_TITLE = "收藏夹"
BINDINGS = [
("q", "go_back", "返回"),
("d", "toggle_dark", ""),
]
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self.favorites: List[FavoriteItem] = []
self._load_favorites()
def _load_favorites(self) -> None:
"""加载收藏列表"""
self.favorites = favorite_manager.get_all()
logger.debug("加载 %d 个收藏项", len(self.favorites))
def compose(self) -> ComposeResult:
"""组合界面组件"""
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="favorites-container"):
if not self.favorites:
yield Label("暂无收藏", classes="empty-label")
yield Static("使用 * 键在记忆界面中添加收藏.")
else:
yield Label(f"{len(self.favorites)} 个收藏项", classes="count-label")
yield ListView(id="favorites-list")
yield Footer()
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def on_mount(self) -> None:
"""挂载后填充列表"""
if self.favorites:
list_view = self.query_one("#favorites-list")
for fav in self.favorites:
list_view.append(self._create_favorite_item(fav)) # type: ignore
def _encode_favorite_key(self, repo_path: str, ident: str) -> str:
"""编码仓库路径和标识符为安全的按钮 ID 部分"""
# 使用 \x00 分隔两部分,然后进行 base64 编码
combined = f"{repo_path}\x00{ident}"
encoded = base64.urlsafe_b64encode(combined.encode()).decode()
# 去掉填充的等号
return encoded.rstrip("=")
def _decode_favorite_key(self, key: str) -> tuple[str, str]:
"""解码按钮 ID 部分为仓库路径和标识符"""
# 补全等号以使长度是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:
"""创建收藏项列表项"""
# 尝试获取仓库信息
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
content_preview = repo_info.get("content_preview", "") if repo_info else ""
added_time = self._format_time(fav.added)
# 构建显示文本
display_text = f"[b]{title}[/b] ({fav.ident})\n"
if content_preview:
display_text += f"{content_preview}\n"
display_text += f"添加于: {added_time}"
if fav.tags:
display_text += f" 标签: {', '.join(fav.tags)}"
# 创建安全的按钮 ID
button_key = self._encode_favorite_key(fav.repo_path, fav.ident)
# 创建列表项,包含移除按钮
container = ScrollableContainer(
Markdown(display_text, classes="favorite-content"),
Button("移除", id=f"remove-{button_key}", variant="error"),
classes="favorite-item",
)
return ListItem(container)
def _get_repo_info(self, repo_path: str, fav: FavoriteItem) -> Optional[dict]:
"""获取仓库信息(标题、原子内容预览)"""
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)
return None
repo = Repo.from_repodir(repo_dir)
# 获取原子内容预览
content_preview = ""
payload = repo.payload
# 查找对应 ident 的 payload 条目
for ident_key, content in payload:
if ident_key == fav.ident:
# 截断过长的内容
if isinstance(content, dict) and "content" in content:
text = content["content"]
else:
text = str(content)
if len(text) > 100:
content_preview = text[:100] + "..."
else:
content_preview = text
break
return {
"title": repo.manifest["title"],
"content_preview": content_preview,
}
except Exception as e:
logger.error("获取仓库信息失败: %s", e)
return None
def _format_time(self, timestamp: int) -> str:
"""格式化时间戳"""
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:
"""处理按钮点击事件"""
button_id = event.button.id
if button_id and button_id.startswith("remove-"):
# 提取编码后的键
key = button_id[7:] # 去掉 "remove-" 前缀
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")
def _remove_favorite(self, repo_path: str, ident: str) -> None:
"""移除收藏项"""
if favorite_manager.remove(repo_path, ident):
self.app.notify(f"已移除收藏: {ident}", severity="information")
# 重新加载列表
self._load_favorites()
# 刷新界面
self._refresh_list()
else:
self.app.notify(f"移除失败: {ident}", severity="error")
def _refresh_list(self) -> None:
"""刷新列表显示"""
container = self.query_one("#favorites-container")
# 清空容器
for child in container.children:
child.remove()
# 重新组合
if not self.favorites:
container.mount(Label("暂无收藏", classes="empty-label"))
container.mount(Static("使用 * 键在记忆界面中添加收藏。"))
else:
container.mount(
Label(f"{len(self.favorites)} 个收藏项", classes="count-label")
)
list_view = ListView(id="favorites-list")
container.mount(list_view)
for fav in self.favorites:
list_view.append(self._create_favorite_item(fav))
def action_go_back(self) -> None:
"""返回上一屏幕"""
self.app.pop_screen()
def action_toggle_dark(self) -> None:
"""切换暗黑模式"""
self.app.dark = not self.app.dark # type: ignore
@@ -1 +0,0 @@
"""整体式记忆工作界面"""
-278
View File
@@ -1,278 +0,0 @@
"""队列式记忆工作界面"""
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import ScrollableContainer
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import Footer, Header, Label, Static
from textual import events, on
import heurams.kernel.particles as pt
from heurams.context import config_var, rootdir
from heurams.kernel.reactor import *
from heurams.services.favorite_service import favorite_manager
from heurams.services.logger import get_logger
from .. import shim
logger = get_logger(__name__)
class MemScreen(Screen):
BINDINGS = [
("q", "go_back", "返回"),
("p", "prev", "查看上一个"),
("d", "toggle_dark", ""),
("v", "play_voice", "朗读"),
("*", "toggle_favorite", "收藏"),
("r", "resume_mark"),
("n", "block_prompt"),
("s", "block_prompt"),
("z", "block_prompt"),
]
SUB_TITLE = "学习中"
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", "错误应答"))
rating = reactive(-1)
def __init__(
self,
router: Router,
repo=None,
name=None,
id=None,
classes=None,
) -> None:
super().__init__(name, id, classes)
self.router = router
self.repo = repo
self.update_state()
self.expander: Expander
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult:
from heurams.services.attic import Attic
a = Attic("ana", {"openqueue": 0})
a.data["openqueue"] += 1
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer():
yield Label(self._get_progress_text(), id="head_stat")
yield ScrollableContainer(id="puzzle_container")
yield Footer()
def update_state(self):
"""更新状态机"""
self.procession: Procession = self.router.current_procession() # type: ignore
self.atom: pt.Atom = self.procession.current_atom # type: ignore
def on_mount(self):
self.expander = self.procession.get_expander()
from heurams.services.attic import Attic
import time
a = Attic("ana", {"last": time.time()})
a.data["last"] = time.time()
self.mount_puzzle()
self.update_display()
def puzzle_widget(self):
try:
puzzle = self.expander.get_current_puzzle_inf()
return shim.puzzle2widget[puzzle["puzzle"]]( # type: ignore
atom=self.atom, alia=puzzle["alia"] # type: ignore
)
except Exception as e:
logger.debug(f"调度展开出错: {e}")
return Static(f"无法生成谜题 {e}")
def _get_progress_text(self):
s = f"阶段: {self.procession.route.name}\n"
# 收藏状态
if self.repo is not None:
fav_status = "已收藏" if self._is_current_atom_favorited() else "未收藏"
s += f"收藏: {fav_status}\n"
s += f"进度: {self.procession.process() + 1}/{self.procession.total_length()}"
return s
def update_display(self):
"""更新进度显示"""
progress_widget = self.query_one("#head_stat")
progress_widget.update(self._get_progress_text()) # type: ignore
def mount_puzzle(self):
"""挂载当前谜题组件"""
if self.procession.route == RouterState.FINISHED:
self.mount_finished_widget()
return
container = self.query_one("#puzzle_container")
for i in container.children:
i.remove()
container.mount(self.puzzle_widget())
def mount_finished_widget(self):
"""挂载已完成组件"""
a = Attic("ana", {"finished": 0})
a.data["finished"] += 1
container = self.query_one("#puzzle_container")
for i in container.children:
i.remove()
from heurams.interface.widgets.finished import Finished
if config_var.get()["interface"]["global"]["persist_to_file"]:
self.save_func()
container.mount(Finished(is_saved=["interface"]["global"]["persist_to_file"]))
def on_button_pressed(self, event):
event.stop()
def action_play_voice(self):
self.run_worker(self.play_voice, exclusive=True, thread=True)
def play_voice(self):
"""朗读当前内容"""
from pathlib import Path
from heurams.services.audio_service import play_by_path
from heurams.services.hasher import get_md5
path = Path(config_var.get()["global"]["paths"]["data"]) / "cache" / "voice"
path = path / f"{get_md5(self.atom.registry['nucleon']["tts_text"])}.wav"
if path.exists():
play_by_path(path)
else:
from heurams.services.tts_service import convertor
convertor(self.atom.registry["nucleon"]["tts_text"], path)
play_by_path(path)
def watch_rating(self, old_rating, new_rating) -> None:
if new_rating == -1: # 安全值
return
self.update_state()
if self.procession.route == RouterState.FINISHED:
rating = -1
return
self.expander.report(new_rating)
self.forward(new_rating)
self.rating = -1
def forward(self, rating):
self.update_state()
allow_forward = 1 if rating >= 4 else 0
if allow_forward:
self.expander.forward()
if self.expander.state == "retronly":
self.forward_atom(self.expander.get_quality())
self.update_state()
from heurams.services.attic import Attic
a = Attic("ana", {"openpuzzles": 0})
a = Attic("ana", {"totaltime": 0})
a.data["openpuzzles"] += 1
import time
a.data["totaltime"] += time.time() - a.data["last"]
a.data["last"] = time.time()
self.mount_puzzle()
self.update_display()
def atom_reporter(self, quality):
if not self.atom.registry["runtime"]["locked"]:
if not self.atom.registry["electron"].is_activated():
self.atom.registry["electron"].activate()
logger.debug(f"激活原子 {self.atom}")
self.atom.lock(1)
self.atom.minimize(5)
else:
self.atom.minimize(quality)
else:
pass
def forward_atom(self, quality):
logger.debug(f"Quality: {quality}")
self.atom_reporter(quality)
if quality <= 3:
from heurams.services.attic import Attic
a = Attic("ana", {"puzzles_err": 0})
a.data["puzzles_err"] += 1
self.procession.append()
self.update_state() # 刷新状态
self.procession.forward(1)
self.update_state() # 刷新状态
self.expander = self.procession.get_expander()
def action_go_back(self):
self.app.pop_screen()
def action_quick_pass(self):
self.rating = 5
def action_quick_fail(self):
self.rating = 3
def _get_repo_rel_path(self) -> str:
"""获取仓库相对路径(相对于 data/repo)"""
if self.repo is None:
return ""
# self.repo.source 是 Path 对象,指向仓库目录
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 下,则返回完整路径(字符串形式)
return str(repo_full_path)
def _is_current_atom_favorited(self) -> bool:
"""检查当前原子是否已收藏"""
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):
"""切换收藏状态"""
if self.repo is None:
self.app.notify("无法收藏:未关联仓库", 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")
else:
favorite_manager.add(repo_path, ident)
self.app.notify(f"已收藏:{ident}", severity="information")
# 更新显示(如果需要)
self.update_display()
def action_block_prompt(self):
self.app.notify("功能在记忆界面中不可用, 完成或返回后再试", severity="error")
def action_resume_mark(self):
from heurams.services.attic import Attic
import time
a = Attic("ana")
l = a.data["last"]
a.data["last"] = time.time()
self.app.notify(f"时间恢复已修正: {l} -> {a.data['last']}")
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
from enum import Enum, auto
from textual.app import ComposeResult
from textual.containers import Center, ScrollableContainer
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Label, Static
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from heurams.context import config_var
from heurams.kernel.reactor import *
from heurams.services.logger import get_logger
from .. import shim
class AtomState(Enum):
FAILED = auto()
NORMAL = auto()
logger = get_logger(__name__)
class MemScreen(Screen):
BINDINGS = [
("q", "pop_screen", "返回"),
# ("p", "prev", "复习上一个"),
("d", "toggle_dark", ""),
("v", "play_voice", "朗读"),
("0,1,2,3", "app.push_screen('about')", ""),
]
if config_var.get()["quick_pass"]:
BINDINGS.append(("k", "quick_pass", "跳过"))
rating = reactive(-1)
def __init__(
self,
atoms: list,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self.atoms = atoms
self.phaser = Phaser(atoms)
# logger.debug(self.phaser.state)
self.procession: Procession = self.phaser.current_procession() # type: ignore
self.atom: pt.Atom = self.procession.current_atom
# logger.debug(self.phaser.state)
# self.procession.forward(1)
for i in atoms:
i.do_eval()
def on_mount(self):
self.load_puzzle()
pass
def puzzle_widget(self):
try:
logger.debug(self.phaser.state)
logger.debug(self.procession.cursor)
logger.debug(self.atom)
self.fission = Fission(self.atom, self.phaser.state)
puzzle_debug = next(self.fission.generate())
# logger.debug(puzzle_debug)
return shim.puzzle2widget[puzzle_debug["puzzle"]](
atom=self.atom, alia=puzzle_debug["alia"]
)
except (KeyError, StopIteration, AttributeError) as e:
logger.debug(f"调度展开出错: {e}")
return Static("无法生成谜题")
# logger.debug(shim.puzzle2widget[puzzle_debug["puzzle"]])
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer():
yield Label(self._get_progress_text(), id="progress")
# self.mount(self.current_widget()) # type: ignore
yield ScrollableContainer(id="puzzle-container")
# yield Button("重新学习此单元", id="re-recognize", variant="warning")
yield Footer()
def _get_progress_text(self):
return f"当前进度: {self.procession.process() + 1}/{self.procession.total_length()}"
def update_display(self):
progress_widget = self.query_one("#progress")
progress_widget.update(self._get_progress_text()) # type: ignore
def load_puzzle(self):
self.atom: pt.Atom = self.procession.current_atom
container = self.query_one("#puzzle-container")
for i in container.children:
i.remove()
container.mount(self.puzzle_widget())
def load_finished_widget(self):
container = self.query_one("#puzzle-container")
for i in container.children:
i.remove()
from heurams.interface.widgets.finished import Finished
container.mount(Finished())
def on_button_pressed(self, event):
event.stop()
def watch_rating(self, old_rating, new_rating) -> None:
if self.procession == 0:
return
if new_rating == -1:
return
forwards = 1 if new_rating >= 4 else 0
self.rating = -1
logger.debug(f"试图前进: {"允许" if forwards else "禁止"}")
if forwards:
ret = self.procession.forward(1)
if ret == 0: # 若结束了此次队列
self.procession = self.phaser.current_procession() # type: ignore
if self.procession == 0: # 若所有队列都结束了
logger.debug(f"记忆进程结束")
for i in self.atoms:
i: pt.Atom
i.revise()
i.persist("electron")
self.load_finished_widget()
return
else:
logger.debug(f"建立新队列 {self.procession.phase}")
self.load_puzzle()
else: # 若不通过
self.procession.append()
self.update_display()
def action_quick_pass(self):
self.rating = 5
self.atom.minimize(5)
self.atom.registry["electron"].activate()
self.atom.lock(1)
def action_play_voice(self):
"""朗读当前内容"""
pass
def action_toggle_dark(self):
self.app.action_toggle_dark()
def action_pop_screen(self):
self.app.pop_screen()
@@ -1,94 +0,0 @@
from textual.app import ComposeResult
from textual.containers import Grid
from textual.screen import ModalScreen
from textual.widgets import Button, Label, ListItem, ListView, Static
from heurams.services.logger import get_logger
from .favmgr import FavoriteManagerScreen
logger = get_logger(__name__)
class NavigatorScreen(ModalScreen):
"""导航器模态窗口"""
BINDINGS = [
("q", "go_back", "返回"),
("escape", "go_back", "返回"),
("n", "go_back", "切换"),
]
SCREENS = [
("仪表盘", "dashboard"),
# ("创建仓库", "repo_creator"),
("缓存管理器", "precache_all"),
("收藏夹", FavoriteManagerScreen),
("设置页面", "setting"),
# ("调试日志", "logviewer"),
("同步工具", "synctool"),
("关于此软件", "about"),
# ("仓库编辑器", "repo_editor"),
]
OTHERS = [
("退出程序", "self.app.exit()"),
("项目主页", "webbrowser.open('https://ams.imwangzhiyu.xyz')"),
]
def compose(self) -> ComposeResult:
"""组合界面组件"""
with Grid(id="dialog"):
yield Label(
"[b]请选择要跳转的功能\n或记忆会话实例[/b]\n\n将在此处显示提示",
classes="title-label",
)
yield ListView(
*[ListItem(Label(title)) for title, _ in (self.SCREENS + self.OTHERS)],
id="nav-list",
classes="nav-list-view",
)
yield Static("按下回车以完成切换\n所有会话将被保存")
yield Button(
"关闭 (n)",
id="close_button",
variant="primary",
classes="close-button",
flat=True,
)
def on_mount(self) -> None:
# 设置焦点到列表
nav_list = self.query_one("#nav-list", ListView)
nav_list.focus()
def on_list_view_selected(self, event) -> None:
if not isinstance(event.item, ListItem):
return
selected_label = event.item.query_one(Label)
label_text = str(selected_label.render())
# 查找对应的屏幕标识
for title, screen_id in self.SCREENS:
if title == label_text:
self.app.pop_screen()
# 跳转到目标屏幕
if isinstance(screen_id, str):
# 已注册的字符串标识符
self.app.push_screen(screen_id)
else:
self.app.push_screen(screen_id())
return
for title, cmd in self.OTHERS:
if title == label_text:
exec(cmd)
return
return
def on_button_pressed(self, event) -> None:
event.stop()
if event.button.id == "close_button":
self.action_go_back()
def action_go_back(self) -> None:
self.app.pop_screen()
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
from pathlib import Path
import toml
from textual.app import ComposeResult
from textual.containers import ScrollableContainer
from textual.screen import Screen
from textual.widgets import (Button, Footer, Header, Input, Label, Markdown,
Select)
from heurams.context import config_var
from heurams.services.version import ver
class NucleonCreatorScreen(Screen):
BINDINGS = [("q", "go_back", "返回")]
SUB_TITLE = "单元集创建向导"
def __init__(self) -> None:
super().__init__(name=None, id=None, classes=None)
def search_templates(self):
from pathlib import Path
from heurams.context import config_var
template_dir = Path(config_var.get()["paths"]["template_dir"])
templates = list()
for i in template_dir.iterdir():
if i.name.endswith(".toml"):
try:
import toml
with open(i, "r") as f:
dic = toml.load(f)
desc = dic["__metadata__.attribution"]["desc"]
templates.append(desc + " (" + i.name + ")")
except Exception as e:
templates.append(f"无描述模板 ({i.name})")
print(e)
return templates
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer(id="vice_container"):
yield Label(f"[b]空白单元集创建向导\n")
yield Markdown(
"> 提示: 你可能注意到当选中文本框时底栏和操作按键绑定将被覆盖 \n只需选中(使用鼠标或 Tab)选择框即可恢复底栏功能"
)
yield Markdown("1. 键入单元集名称")
yield Input(placeholder="单元集名称", id="name_input")
yield Markdown(
"> 单元集名称不应与现有单元集重复. \n> 新的单元集文件将创建在 ./nucleon/你输入的名称.toml"
)
yield Label(f"\n")
yield Markdown("2. 选择单元集模板")
LINES = self.search_templates()
"""带有宏支持的空白单元集 ({ver})
古诗词模板单元集 ({ver})
英语词汇和短语模板单元集 ({ver})
"""
yield Select.from_values(LINES, prompt="选择类型", id="template_select")
yield Markdown("> 新单元集的版本号将和主程序版本保持同步")
yield Label(f"\n")
yield Markdown("3. 输入常见附加元数据 (可选)")
yield Input(placeholder="作者", id="author_input")
yield Input(placeholder="内容描述", id="desc_input")
yield Button(
"新建空白单元集",
id="submit_button",
variant="primary",
classes="start-button",
)
yield Footer()
def on_mount(self):
self.query_one("#submit_button").focus()
def action_go_back(self):
self.app.pop_screen()
def action_quit_app(self):
self.app.exit()
def on_button_pressed(self, event) -> None:
event.stop()
if event.button.id == "submit_button":
# 获取输入值
name_input = self.query_one("#name_input")
template_select = self.query_one("#template_select")
author_input = self.query_one("#author_input")
desc_input = self.query_one("#desc_input")
name = name_input.value.strip() # type: ignore
author = author_input.value.strip() # type: ignore
desc = desc_input.value.strip() # type: ignore
selected = template_select.value # type: ignore
# 验证
if not name:
self.notify("单元集名称不能为空", severity="error")
return
# 获取配置路径
config = config_var.get()
nucleon_dir = Path(config["paths"]["nucleon_dir"])
template_dir = Path(config["paths"]["template_dir"])
# 检查文件是否已存在
nucleon_path = nucleon_dir / f"{name}.toml"
if nucleon_path.exists():
self.notify(f"单元集 '{name}' 已存在", severity="error")
return
# 确定模板文件
if selected is None:
self.notify("请选择一个模板", severity="error")
return
# selected 是描述字符串, 格式如 "描述 (filename.toml)"
# 提取文件名
import re
match = re.search(r"\(([^)]+)\)$", selected)
if not match:
self.notify("模板选择格式无效", severity="error")
return
template_filename = match.group(1)
template_path = template_dir / template_filename
if not template_path.exists():
self.notify(f"模板文件不存在: {template_filename}", severity="error")
return
# 加载模板
try:
with open(template_path, "r", encoding="utf-8") as f:
template_data = toml.load(f)
except Exception as e:
self.notify(f"加载模板失败: {e}", severity="error")
return
# 更新元数据
metadata = template_data.get("__metadata__", {})
attribution = metadata.get("attribution", {})
if author:
attribution["author"] = author
if desc:
attribution["desc"] = desc
attribution["name"] = name
# 可选: 设置版本
attribution["version"] = ver
metadata["attribution"] = attribution
template_data["__metadata__"] = metadata
# 确保 nucleon_dir 存在
nucleon_dir.mkdir(parents=True, exist_ok=True)
# 写入新文件
try:
with open(nucleon_path, "w", encoding="utf-8") as f:
toml.dump(template_data, f)
except Exception as e:
self.notify(f"保存单元集失败: {e}", severity="error")
return
self.notify(f"单元集 '{name}' 创建成功")
self.app.pop_screen()
+56 -154
View File
@@ -1,32 +1,16 @@
"""缓存工具界面""" #!/usr/bin/env python3
import pathlib import pathlib
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal, ScrollableContainer, Container from textual.containers import Horizontal, ScrollableContainer
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Label, ProgressBar, Static from textual.widgets import Button, Footer, Header, Label, ProgressBar, Static
from textual.worker import get_current_worker from textual.worker import get_current_worker
from textual import events, on
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
import heurams.services.hasher as hasher import heurams.services.hasher as hasher
from heurams.context import * from heurams.context import *
# 兼容性缓存路径:优先使用 paths.cache,否则使用 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:
"""将字节数格式化为人类可读的字符串"""
for unit in ["B", "KB", "MB", "GB", "TB"]:
if bytes_num < 1024.0:
return f"{bytes_num:.2f} {unit}"
bytes_num /= 1024.0 # type: ignore
return f"{bytes_num:.2f} PB"
class PrecachingScreen(Screen): class PrecachingScreen(Screen):
"""预缓存音频文件屏幕 """预缓存音频文件屏幕
@@ -39,9 +23,7 @@ class PrecachingScreen(Screen):
""" """
SUB_TITLE = "缓存管理器" SUB_TITLE = "缓存管理器"
BINDINGS = [ BINDINGS = [("q", "go_back", "返回")]
("q", "go_back", "返回"),
]
def __init__(self, nucleons: list = [], desc: str = ""): def __init__(self, nucleons: list = [], desc: str = ""):
super().__init__(name=None, id=None, classes=None) super().__init__(name=None, id=None, classes=None)
@@ -55,109 +37,34 @@ class PrecachingScreen(Screen):
self.precache_worker = None self.precache_worker = None
self.cancel_flag = 0 self.cancel_flag = 0
self.desc = desc self.desc = desc
# 不再需要缓存配置,保留配置读取以兼容 for i in nucleons:
self.cache_stats = { i: pt.Nucleon
"total_size": 0, i.do_eval()
"file_count": 0, # print("完成 EVAL")
"human_size": "0 B",
"cached_units": 0,
"total_units": 0,
"cache_rate": 0,
}
self._update_cache_stats()
def _get_total_units(self) -> int:
"""获取所有仓库的总单元数"""
from heurams.context import config_var
from heurams.kernel.repolib import Repo
repo_path = pathlib.Path(config_var.get()["global"]["paths"]["data"]) / "repo"
repo_dirs = Repo.probe_valid_repos_in_dir(repo_path)
repos = map(Repo.from_repodir, repo_dirs)
total = 0
for repo in repos:
try:
total += len(repo.ident_index)
except:
continue
return total
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def _update_cache_stats(self) -> None:
"""更新缓存统计信息"""
total_size = 0
file_count = 0
cached_units = 0
if cache_dir.exists():
for file in cache_dir.rglob("*"):
if file.is_file():
total_size += file.stat().st_size
file_count += 1
if file.suffix.lower() == ".wav":
cached_units += 1
total_units = self._get_total_units()
cache_rate = (cached_units / total_units * 100) if total_units > 0 else 0
self.cache_stats["total_size"] = total_size
self.cache_stats["file_count"] = file_count
self.cache_stats["human_size"] = human_size(total_size)
self.cache_stats["cached_units"] = cached_units
self.cache_stats["total_units"] = total_units
self.cache_stats["cache_rate"] = cache_rate
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True)
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="precache_container"): with ScrollableContainer(id="precache_container"):
yield Label("[b]音频预缓存[/b]", classes="title-label") yield Label("[b]音频预缓存[/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)} 个单元)",
classes="cache-usage-text",
)
if self.nucleons: if self.nucleons:
yield Static( yield Static(f"目标单元归属: [b]{self.desc}[/b]", classes="target-info")
f"目标单元归属: [b]{self.desc}[/b]", classes="target-info" yield Static(f"单元数量: {len(self.nucleons)}", classes="target-info")
)
yield Static(
f"单元数量: {len(self.nucleons)}", classes="target-info"
)
else: else:
yield Static("目标: 所有单元", classes="target-info") yield Static("目标: 所有单元", classes="target-info")
yield Static(id="status", classes="status-info") yield Static(id="status", classes="status-info")
yield Static(id="current_item", classes="current-item") yield Static(id="current_item", classes="current-item")
yield ProgressBar(total=100, show_eta=False, id="progress_bar") yield ProgressBar(total=100, show_eta=False, id="progress_bar")
with Horizontal(classes="button-group"): with Horizontal(classes="button-group"):
if not self.is_precaching: if not self.is_precaching:
yield Button( yield Button("开始预缓存", id="start_precache", variant="primary")
"开始预缓存", id="start_precache", variant="primary"
)
else: else:
yield Button( yield Button("取消预缓存", id="cancel_precache", variant="error")
"取消预缓存", id="cancel_precache", variant="error"
)
yield Button("清空缓存", id="clear_cache", variant="warning") yield Button("清空缓存", id="clear_cache", variant="warning")
yield Button("返回", id="go_back", variant="default") yield Button("返回", id="go_back", variant="default")
with Container(classes="cache-info"):
yield Static(f"缓存路径: {cache_dir}", classes="cache-path")
yield Static(
f"文件数: {self.cache_stats['file_count']}", classes="cache-count"
)
yield Static(
f"总大小: {self.cache_stats['human_size']}", classes="cache-size"
)
yield Button(
"刷新", id="refresh_cache_stats", variant="default", flat=True
)
yield Static("若您离开此界面, 未完成的缓存进程会自动停止.") yield Static("若您离开此界面, 未完成的缓存进程会自动停止.")
yield Static('缓存程序支持 "断点续传".') yield Static('缓存程序支持 "断点续传".')
@@ -166,7 +73,6 @@ class PrecachingScreen(Screen):
def on_mount(self): def on_mount(self):
"""挂载时初始化状态""" """挂载时初始化状态"""
self.update_status("就绪", "等待开始...") self.update_status("就绪", "等待开始...")
self._update_cache_display()
def update_status(self, status, current_item="", progress=None): def update_status(self, status, current_item="", progress=None):
"""更新状态显示""" """更新状态显示"""
@@ -181,35 +87,19 @@ class PrecachingScreen(Screen):
progress_bar.progress = progress progress_bar.progress = progress
progress_bar.advance(0) # 刷新显示 progress_bar.advance(0) # 刷新显示
def _update_cache_display(self) -> None:
"""更新缓存信息显示"""
# 更新统计信息
self._update_cache_stats()
# 更新缓存率进度条
# 更新缓存大小和文件数显示
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']}")
if cache_size_widget:
cache_size_widget.update(f"总大小: {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)} 个单元)"
)
def precache_by_text(self, text: str): def precache_by_text(self, text: str):
"""预缓存单段文本的音频""" """预缓存单段文本的音频"""
from heurams.context import config_var, rootdir, workdir
cache_dir = pathlib.Path(config_var.get()["paths"]["cache_dir"])
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / f"{hasher.get_md5(text)}.wav" cache_file = cache_dir / f"{hasher.get_md5(text)}.wav"
if not cache_file.exists(): if not cache_file.exists():
try: try: # TODO: 调用模块消除tts耦合
from heurams.services.tts_service import convertor import edge_tts as tts
convertor(text, cache_file) communicate = tts.Communicate(text, "zh-CN-XiaoxiaoNeural")
communicate.save_sync(str(cache_file))
return 1 return 1
except Exception as e: except Exception as e:
print(f"预缓存失败 '{text}': {e}") print(f"预缓存失败 '{text}': {e}")
@@ -218,8 +108,10 @@ class PrecachingScreen(Screen):
def precache_by_nucleon(self, nucleon: pt.Nucleon): def precache_by_nucleon(self, nucleon: pt.Nucleon):
"""依据 Nucleon 缓存""" """依据 Nucleon 缓存"""
ret = self.precache_by_text(nucleon["tts_text"]) # print(nucleon.metadata['formation']['tts_text'])
ret = self.precache_by_text(nucleon.metadata["formation"]["tts_text"])
return ret return ret
# print(f"TTS 缓存: {nucleon.metadata['formation']['tts_text']}")
def precache_by_list(self, nucleons: list): def precache_by_list(self, nucleons: list):
"""依据 Nucleons 列表缓存""" """依据 Nucleons 列表缓存"""
@@ -228,7 +120,7 @@ class PrecachingScreen(Screen):
worker = get_current_worker() worker = get_current_worker()
if worker and worker.is_cancelled: # 函数在worker中执行且已被取消 if worker and worker.is_cancelled: # 函数在worker中执行且已被取消
return False return False
text = nucleon["tts_text"] text = nucleon.metadata["formation"]["tts_text"]
# self.current_item = text[:30] + "..." if len(text) > 50 else text # self.current_item = text[:30] + "..." if len(text) > 50 else text
# print(text) # print(text)
self.processed += 1 self.processed += 1
@@ -258,30 +150,36 @@ class PrecachingScreen(Screen):
# print(f"返回 {ret}") # print(f"返回 {ret}")
return ret return ret
def precache_by_filepath(self, path: pathlib.Path):
"""预缓存单个文件的所有内容"""
lst = list()
for i in pt.load_nucleon(path):
lst.append(i[0])
return self.precache_by_list(lst)
def precache_all_files(self): def precache_all_files(self):
"""预缓存所有文件""" """预缓存所有文件"""
from heurams.context import config_var from heurams.context import config_var, rootdir, workdir
from heurams.kernel.repolib import Repo
repo_path = pathlib.Path(config_var.get()["global"]["paths"]["data"]) / "repo" nucleon_path = pathlib.Path(config_var.get()["paths"]["nucleon_dir"])
repo_dirs = Repo.probe_valid_repos_in_dir(repo_path) nucleon_files = [
repos = map(Repo.from_repodir, repo_dirs) f for f in nucleon_path.iterdir() if f.suffix == ".toml"
] # TODO: 解耦合
# 计算总项目数 # 计算总项目数
self.total = 0 self.total = 0
nucleon_list = list() nu = list()
for repo in repos: for file in nucleon_files:
try: try:
for i in repo.ident_index: for i in pt.load_nucleon(file):
nucleon_list.append( nu.append(i[0])
pt.Nucleon.from_data(
repo.nucleonic_data_lict.get_itemic_unit(i)
)
)
except: except:
continue continue
self.total = len(nucleon_list) self.total = len(nu)
return self.precache_by_list(nucleon_list) for i in nu:
i: pt.Nucleon
i.do_eval()
return self.precache_by_list(nu)
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop() event.stop()
@@ -316,19 +214,18 @@ class PrecachingScreen(Screen):
try: try:
import shutil import shutil
shutil.rmtree(cache_dir, ignore_errors=True) from heurams.context import config_var, rootdir, workdir
shutil.rmtree(
f"{config_var.get()["paths"]["cache_dir"]}", ignore_errors=True
)
self.update_status("已清空", "音频缓存已清空", 0) self.update_status("已清空", "音频缓存已清空", 0)
self._update_cache_display() # 更新缓存统计显示
except Exception as e: except Exception as e:
self.update_status("错误", f"清空缓存失败: {e}") self.update_status("错误", f"清空缓存失败: {e}")
self.cancel_flag = 1 self.cancel_flag = 1
self.processed = 0 self.processed = 0
self.progress = 0 self.progress = 0
elif event.button.id == "refresh_cache_stats":
# 刷新缓存统计信息
self._update_cache_display()
self.app.notify("缓存信息已刷新", severity="information")
elif event.button.id == "go_back": elif event.button.id == "go_back":
self.action_go_back() self.action_go_back()
@@ -336,3 +233,8 @@ class PrecachingScreen(Screen):
if self.is_precaching and self.precache_worker: if self.is_precaching and self.precache_worker:
self.precache_worker.cancel() self.precache_worker.cancel()
self.app.pop_screen() self.app.pop_screen()
def action_quit_app(self):
if self.is_precaching and self.precache_worker:
self.precache_worker.cancel()
self.app.exit()
+66 -125
View File
@@ -1,26 +1,15 @@
"""记忆准备界面""" #!/usr/bin/env python3
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import ScrollableContainer, Horizontal from textual.containers import ScrollableContainer
from textual.reactive import reactive
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import ( from textual.widget import Widget
Button, from textual.widgets import Button, Footer, Header, Label, Markdown, Static
Footer,
Header,
Label,
Markdown,
Static,
Sparkline,
)
from textual.lazy import Reveal
from textual import events, on
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
import heurams.services.hasher as hasher
from heurams.context import * from heurams.context import *
from heurams.context import config_var from heurams.context import config_var
from heurams.kernel.repolib import *
from heurams.kernel.algorithms import algorithms
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -32,71 +21,48 @@ class PreparationScreen(Screen):
BINDINGS = [ BINDINGS = [
("q", "go_back", "返回"), ("q", "go_back", "返回"),
("p", "precache", "缓存"), ("p", "precache", "缓存音频"),
("d", "toggle_dark", ""), ("d", "toggle_dark", ""),
("0,1,2,3", "app.push_screen('about')", ""), ("0,1,2,3", "app.push_screen('about')", ""),
] ]
CSS_PATH = rootdir / "interface" / "css" / "screens" / "preparation.tcss" scheduled_num = reactive(config_var.get()["scheduled_num"])
def __init__(self, repo: Repo) -> None: def __init__(self, nucleon_file: pathlib.Path, electron_file: pathlib.Path) -> None:
super().__init__(name=None, id=None, classes=None) super().__init__(name=None, id=None, classes=None)
self.repo = repo self.nucleon_file = nucleon_file
self.load_data() self.electron_file = electron_file
self.nucleons_with_orbital = pt.load_nucleon(self.nucleon_file)
@on(events.ScreenResume) self.electrons = pt.load_electron(self.electron_file)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
from heurams.services.attic import Attic yield Header(show_clock=True)
with ScrollableContainer(id="vice_container"):
yield Label(f"准备就绪: [b]{self.nucleon_file.stem}[/b]\n")
yield Label(
f"内容源文件: {config_var.get()['paths']['nucleon_dir']}/[b]{self.nucleon_file.name}[/b]"
)
yield Label(
f"元数据文件: {config_var.get()['paths']['electron_dir']}/[b]{self.electron_file.name}[/b]"
)
yield Label(f"\n单元数量: {len(self.nucleons_with_orbital)}\n")
yield Label(f"单次记忆数量: {self.scheduled_num}", id="schnum_label")
a = Attic("ana", {"openpre": 0}) yield Button(
a.data["openpre"] += 1
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="main_container"):
yield Markdown(
f"**准备就绪**: `{self.repo.manifest['title']}`\n", id="title"
)
yield Label(f"单元集路径: {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]"
)
yield Label(
f"调度算法: {self.repo.config["algorithm"]} {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",
id="schnum_label",
)
yield Horizontal(
Button(
"开始记忆", "开始记忆",
id="start_memorizing_button", id="start_memorizing_button",
variant="primary", variant="primary",
classes="btn", classes="start-button",
), )
Button( yield Button(
"管理缓存", "预缓存音频",
id="precache_button", id="precache_button",
variant="success", variant="success",
classes="btn", classes="precache-button",
),
id="operations",
) )
yield Static() yield Static(f"\n单元预览:\n")
yield Sparkline(self.spark_line_arr, summary_function=max) yield Markdown(self._get_full_content().replace("/", ""), classes="full")
# yield Static(str(self.spark_line_arr))
with Reveal(ScrollableContainer(id="previewer_container")):
for i in self.content.splitlines():
yield Static(i, classes="unit-statline")
yield Footer() yield Footer()
# def watch_scheduled_num(self, old_scheduled_num, new_scheduled_num): # def watch_scheduled_num(self, old_scheduled_num, new_scheduled_num):
@@ -107,31 +73,13 @@ class PreparationScreen(Screen):
# except: # except:
# pass # pass
def load_data(self): def _get_full_content(self):
self.scheduled_num = self.repo.config["scheduled_num"]
content = "" content = ""
spark_line_arr = [] for nucleon, orbital in self.nucleons_with_orbital:
for i in self.repo.ident_index: nucleon: pt.Nucleon
n = pt.Nucleon.from_data( # print(nucleon.payload)
nucleonic_data=self.repo.nucleonic_data_lict.get_itemic_unit(i) content += " - " + nucleon["content"] + " \n"
) return content
e = pt.Electron.from_data(
electronic_data=self.repo.electronic_data_lict.get_itemic_unit(i),
algo_name=self.repo.config["algorithm"],
)
statstr = ""
if e.is_activated():
statstr = "[#00ff00]A[/]"
if e.is_due():
statstr = "[#ffff00]R[/]"
# statstr += ('[dim]' + str(e.rept(real_rept=True)).zfill(2)+'[/]')
else:
statstr = "[#ff0000]U[/]"
spark_line_arr.append(e.rept(real_rept=True))
content += f" {statstr} {n['content'].replace('/', '')} \n"
self.content = content
self.spark_line_arr = spark_line_arr
def action_go_back(self): def action_go_back(self):
self.app.pop_screen() self.app.pop_screen()
@@ -140,13 +88,9 @@ class PreparationScreen(Screen):
from ..screens.precache import PrecachingScreen from ..screens.precache import PrecachingScreen
lst = list() lst = list()
for i in self.repo.ident_index: for i in self.nucleons_with_orbital:
lst.append( lst.append(i[0])
pt.Nucleon.from_data(self.repo.nucleonic_data_lict.get_itemic_unit(i)) precache_screen = PrecachingScreen(lst)
)
precache_screen = PrecachingScreen(
nucleons=lst, desc=self.repo.manifest["title"]
)
self.app.push_screen(precache_screen) self.app.push_screen(precache_screen)
def action_quit_app(self): def action_quit_app(self):
@@ -156,42 +100,39 @@ class PreparationScreen(Screen):
event.stop() event.stop()
logger.debug("按下按钮") logger.debug("按下按钮")
if event.button.id == "start_memorizing_button": if event.button.id == "start_memorizing_button":
launch(repo=self.repo, app=self.app, scheduled_num=self.scheduled_num)
elif event.button.id == "precache_button":
self.action_precache()
def launch(repo, app, scheduled_num):
if scheduled_num == -1:
scheduled_num = config_var.get()["interface"]["global"]["scheduled_num"]
atoms = list() atoms = list()
for i in repo.ident_index: for nucleon, orbital in self.nucleons_with_orbital:
n = pt.Nucleon.from_data( atom = pt.Atom(nucleon.ident)
nucleonic_data=repo.nucleonic_data_lict.get_itemic_unit(i) atom.link("nucleon", nucleon)
) try:
e = pt.Electron.from_data( atom.link("electron", self.electrons[nucleon.ident])
electronic_data=repo.electronic_data_lict.get_itemic_unit(i), except KeyError:
algo_name=repo.config["algorithm"], atom.link("electron", pt.Electron(nucleon.ident))
) atom.link("orbital", orbital)
a = pt.Atom(n, e, repo.orbitic_data) atom.link("nucleon_fmt", "toml")
atoms.append(a) atom.link("electron_fmt", "json")
atom.link("orbital_fmt", "toml")
atom.link("nucleon_path", self.nucleon_file)
atom.link("electron_path", self.electron_file)
atom.link("orbital_path", None)
atoms.append(atom)
atoms_to_provide = list() atoms_to_provide = list()
left_new = scheduled_num left_new = self.scheduled_num
for i in atoms: for i in atoms:
i: pt.Atom i: pt.Atom
if i.registry["electron"].is_activated():
if i.registry["electron"].is_due(): if i.registry["electron"].is_due():
atoms_to_provide.append(i) atoms_to_provide.append(i)
else:
if i.registry["electron"].is_activated():
pass
else: else:
left_new -= 1 left_new -= 1
if left_new >= 0: if left_new >= 0:
atoms_to_provide.append(i) atoms_to_provide.append(i)
import heurams.kernel.reactor as rt logger.debug(f"ATP: {atoms_to_provide}")
from .memorizor import MemScreen
from .memoqueue import MemScreen memscreen = MemScreen(atoms_to_provide)
self.app.push_screen(memscreen)
router = rt.Router(atoms_to_provide) elif event.button.id == "precache_button":
memscreen = MemScreen(router=router, repo=repo) self.action_precache()
app.push_screen(memscreen)
-246
View File
@@ -1,246 +0,0 @@
"""设置页面"""
from textual.app import ComposeResult
from textual.containers import ScrollableContainer, Horizontal
from textual.screen import Screen
from textual.widgets import (
Footer,
Header,
Label,
Collapsible,
Input,
Switch,
Select,
)
from textual import events, on
from heurams.context import *
from heurams.kernel.particles import *
from heurams.kernel.repolib import *
from heurams.services.logger import get_logger
from heurams.services.textproc import domize, undomize
from heurams.services.epath import epath
logger = get_logger(__name__)
class SettingScreen(Screen):
"""设置页面屏幕"""
SUB_TITLE = "设置"
BINDINGS = [
("q", "go_back", "返回"),
("s", "go_back", "设置"),
]
CSS_PATH = rootdir / "interface" / "css" / "screens" / "setting.tcss"
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult:
"""组合界面组件"""
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]")
for i in config_var.get():
if i.startswith("_"):
continue
a = self._get_subcfg(f"{i}")
if a:
yield Collapsible(
*a,
title=i + f'\n[d]{config_var.get().get(f"_{i}_desc", "")}[/d]',
)
yield Label(
"退出页面时, 所作的更改会立即保存, 但仍建议重启软件以确保新的配置得到应用",
classes="foot",
)
yield Footer()
def _get_subcfg(self, parent_epath: str):
parent = epath(config_var.get(), parent_epath)
if isinstance(parent, ConfigDict):
if parent.is_dir:
lst = list()
for i in parent:
if i.startswith("_"):
continue
a = self._get_subcfg(f"{parent_epath}.{i}")
if a:
lst.append(
Collapsible(
*a, title=i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'
)
)
return lst
if isinstance(parent, dict) or (
isinstance(parent, ConfigDict) and not parent.is_dir
):
lst = list()
for i in parent:
if i.startswith("_"):
continue
if isinstance(parent[i], dict):
a = self._get_subcfg(f"{parent_epath}.{i}")
if a:
lst.append(
Collapsible(
*a, title=i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'
)
)
elif f"_{i}_candidate" in parent: # 选择框模式
if isinstance(parent[f"_{i}_candidate"], dict):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Select(
(
(f"{j}\n[d]{k}[/d]", j)
for j, k in parent[f"_{i}_candidate"].items()
),
prompt=f'{parent.get(f"{i}", "")}',
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
elif isinstance(parent[f"_{i}_candidate"], list):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Select(
((j, j) for j in parent[f"_{i}_candidate"]),
prompt=f'{parent.get(f"{i}", "")}',
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
else:
if isinstance(parent[i], float):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Input(
value=str(parent[i]),
placeholder="要求一个浮点数",
type="number",
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
elif isinstance(parent[i], str):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Input(
value=parent[i],
placeholder="要求一个字符串",
type="text",
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
elif isinstance(parent[i], bool):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Switch(
value=parent[i],
id=domize(f"{parent_epath}.{i}"),
classes="setting-switch",
),
classes="setting-item",
)
)
elif isinstance(parent[i], int):
lst.append(
Horizontal(
Label(i + f'\n[d]{parent.get(f"_{i}_desc", "")}[/d]'),
Input(
value=str(parent[i]),
placeholder="要求一个整数",
type="integer",
id=domize(f"{parent_epath}.{i}"),
),
classes="setting-item",
)
)
elif isinstance(parent[i], list):
pass
else:
lst.append(Label("未知类型"))
return lst
return [Label("无子项")]
def on_mount(self) -> None:
"""挂载组件时初始化"""
def action_go_back(self) -> None:
"""返回上一屏幕"""
config_var.get().persist()
self.app.pop_screen()
def action_quit_app(self) -> None:
"""退出应用程序"""
self.app.exit()
def action_open_navigator(self) -> None:
"""打开导航器"""
self.app.push_screen(NavigatorScreen())
def on_input_changed(self, event: Input.Changed) -> None:
widget_id = event.input.id
if not widget_id:
return
eepath = undomize(widget_id)
value = event.value
epath(
config_var.get(),
eepath,
enable_modify=True,
new_value=type(epath(config_var.get(), eepath))(value),
)
def on_switch_changed(self, event: Switch.Changed) -> None:
widget_id = event.switch.id
if not widget_id:
return
eepath = undomize(widget_id)
value = event.value
epath(
config_var.get(),
eepath,
enable_modify=True,
new_value=type(epath(config_var.get(), eepath))(value),
)
def on_select_changed(self, event: Select.Changed) -> None:
widget_id = event.select.id
if not widget_id:
return
eepath = undomize(widget_id)
value = event.value
epath(
config_var.get(),
eepath,
enable_modify=True,
new_value=type(epath(config_var.get(), eepath))(value),
)
+6 -283
View File
@@ -1,16 +1,14 @@
"""同步工具界面""" #!/usr/bin/env python3
import pathlib import pathlib
import time
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal, ScrollableContainer from textual.containers import Horizontal, ScrollableContainer
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Button, Footer, Header, ProgressBar, Static from textual.widgets import Button, Footer, Header, Label, ProgressBar, Static
from textual.worker import get_current_worker from textual.worker import get_current_worker
from textual import events, on import heurams.kernel.particles as pt
import heurams.services.hasher as hasher
from heurams.context import * from heurams.context import *
@@ -20,297 +18,22 @@ class SyncScreen(Screen):
def __init__(self, nucleons: list = [], desc: str = ""): def __init__(self, nucleons: list = [], desc: str = ""):
super().__init__(name=None, id=None, classes=None) super().__init__(name=None, id=None, classes=None)
self.sync_service = None
self.is_syncing = False
self.is_paused = False
self.log_messages = []
self.max_log_lines = 50
@on(events.ScreenResume)
def post_active(self, event):
from heurams.interface import shim
shim.set_term_title(f"{self.app.TITLE} - {self.SUB_TITLE}")
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True)
if config_var.get()["interface"]["global"]["show_header"]:
yield Header(
show_clock=config_var.get()["interface"]["global"]["clock_on_header"]
)
with ScrollableContainer(id="sync_container"): with ScrollableContainer(id="sync_container"):
# 标题和连接状态 pass
yield Static("同步工具", classes="title")
yield Static("", id="status_label", classes="status")
# 配置信息
yield Static(f"同步协议: {config_var.get()['services']['sync']}")
yield Static("服务器配置:", classes="section_title")
with Horizontal(classes="config_info"):
yield Static("远程服务器:", classes="config_label")
yield Static("", id="server_url", classes="config_value")
with Horizontal(classes="config_info"):
yield Static("远程路径:", 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 Static("同步进度", 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("", id="log_output", classes="log_output")
yield Footer() yield Footer()
def on_mount(self): def on_mount(self):
"""挂载时初始化状态""" """挂载时初始化状态"""
self.update_ui_from_config()
self.log_message("同步工具已启动")
def update_ui_from_config(self):
"""更新 UI 显示配置信息"""
try:
sync_cfg: dict = config_var.get()["providers"]["sync"]["webdav"]
# 更新服务器 URL
url = sync_cfg.get("url", "未配置")
url_widget = self.query_one("#server_url")
url_widget.update(url) # type: ignore
# 更新远程路径
remote_path = sync_cfg.get("remote_path", "/")
path_widget = self.query_one("#remote_path")
path_widget.update(remote_path) # type: ignore
# 更新状态标签
status_widget = self.query_one("#status_label")
if self.sync_service and self.sync_service.client:
status_widget.update("✅ 同步服务已就绪") # type: ignore
status_widget.add_class("ready")
else:
status_widget.update("❌ 同步服务未配置或未启用") # type: ignore
status_widget.add_class("error")
except Exception as e:
self.log_message(f"更新 UI 失败: {e}", is_error=True)
def update_status(self, status, current_item="", progress=None): def update_status(self, status, current_item="", progress=None):
"""更新状态显示""" """更新状态显示"""
try:
status_widget = self.query_one("#status_label")
status_widget.update(status) # type: ignore
if progress is not None:
progress_bar = self.query_one("#progress_bar")
progress_bar.progress = progress # type: ignore
progress_label = self.query_one("#progress_label")
progress_label.update(f"{progress}% - {current_item}" if current_item else f"{progress}%") # type: ignore
except Exception as e:
self.log_message(f"更新状态失败: {e}", is_error=True)
def log_message(self, message: str, is_error: bool = False):
"""添加日志消息并更新显示"""
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)
# 保持日志行数不超过最大值
if len(self.log_messages) > self.max_log_lines:
self.log_messages = self.log_messages[-self.max_log_lines :]
# 更新日志显示
try:
log_widget = self.query_one("#log_output")
log_widget.update("\n".join(self.log_messages)) # type: ignore
except Exception:
pass # 如果组件未就绪,忽略错误
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""处理按钮点击事件"""
button_id = event.button.id
if button_id == "test_connection":
self.test_connection()
elif button_id == "start_sync":
self.start_sync()
elif button_id == "pause_sync":
self.pause_sync()
elif button_id == "cancel_sync":
self.cancel_sync()
event.stop() event.stop()
def test_connection(self):
"""测试 WebDAV 服务器连接"""
if not self.sync_service:
self.log_message("同步服务未初始化,请检查配置", is_error=True)
self.update_status("❌ 同步服务未初始化")
return
self.log_message("正在测试 WebDAV 连接...")
self.update_status("正在测试连接...")
try:
success = self.sync_service.test_connection()
if success:
self.log_message("连接测试成功")
self.update_status("✅ 连接正常")
else:
self.log_message("连接测试失败", is_error=True)
self.update_status("❌ 连接失败")
except Exception as e:
self.log_message(f"连接测试异常: {e}", is_error=True)
self.update_status("❌ 连接异常")
def start_sync(self):
"""开始同步"""
if not self.sync_service:
self.log_message("同步服务未初始化,无法开始同步", is_error=True)
return
if self.is_syncing:
self.log_message("同步已在进行中", 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.run_worker(self.perform_sync, thread=True)
def perform_sync(self):
"""执行同步任务(在后台线程中运行)"""
worker = get_current_worker()
try:
# 获取需要同步的本地目录
from heurams.context import config_var
config = config_var.get()
paths = config.get("paths", {})
# 同步 nucleon 目录
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)
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)}"
)
else:
self.log_message(
f"nucleon 同步失败: {result.get('error', '未知错误')}",
is_error=True,
)
# 同步 electron 目录
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)
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)}"
)
else:
self.log_message(
f"electron 同步失败: {result.get('error', '未知错误')}",
is_error=True,
)
# 同步 orbital 目录(如果存在)
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)
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)}"
)
else:
self.log_message(
f"orbital 同步失败: {result.get('error', '未知错误')}",
is_error=True,
)
# 同步完成
self.update_status("同步完成", progress=100)
self.log_message("所有目录同步完成")
except Exception as e:
self.log_message(f"同步过程中发生错误: {e}", is_error=True)
self.update_status("同步失败")
finally:
# 重置同步状态
self.is_syncing = False
self.is_paused = False
self.update_button_states() # type: ignore
def pause_sync(self):
"""暂停同步"""
if not self.is_syncing:
return
self.is_paused = not self.is_paused
self.update_button_states()
if self.is_paused:
self.log_message("同步已暂停")
self.update_status("同步已暂停")
else:
self.log_message("同步已恢复")
self.update_status("正在同步...")
def cancel_sync(self):
"""取消同步"""
if not self.is_syncing:
return
self.is_syncing = False
self.is_paused = False
self.update_button_states()
self.log_message("同步已取消")
self.update_status("同步已取消")
def update_button_states(self):
"""更新按钮状态"""
try:
start_button = self.query_one("#start_sync")
pause_button = self.query_one("#pause_sync")
cancel_button = self.query_one("#cancel_sync")
if self.is_syncing:
start_button.disabled = True
pause_button.disabled = False
cancel_button.disabled = False
pause_button.label = "继续" if self.is_paused else "暂停" # 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)
def action_go_back(self): def action_go_back(self):
self.app.pop_screen() self.app.pop_screen()
+26 -13
View File
@@ -1,10 +1,33 @@
"""Kernel 操作辅助函数库""" """Kernel 操作辅助函数库"""
import random
from typing import TypedDict
import heurams.interface.widgets as pzw import heurams.interface.widgets as pzw
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz import heurams.kernel.puzzles as pz
import platform
import os staging = {} # 细粒度缓存区, 是 ident -> quality 的封装
from heurams.context import config_var
def report_to_staging(atom: pt.Atom, quality):
staging[atom.ident] = min(quality, staging[atom.ident])
def clear():
staging = dict()
def deploy_to_electron():
for atom_ident, quality in staging.items():
if pt.atom_registry[atom_ident].registry["electron"].is_activated:
pt.atom_registry[atom_ident].registry["electron"].revisor(quality=quality)
else:
pt.atom_registry[atom_ident].registry["electron"].revisor(
quality=quality, is_new_activation=True
)
clear()
puzzle2widget = { puzzle2widget = {
pz.RecognitionPuzzle: pzw.Recognition, pz.RecognitionPuzzle: pzw.Recognition,
@@ -12,13 +35,3 @@ puzzle2widget = {
pz.MCQPuzzle: pzw.MCQPuzzle, pz.MCQPuzzle: pzw.MCQPuzzle,
pz.BasePuzzle: pzw.BasePuzzleWidget, pz.BasePuzzle: pzw.BasePuzzleWidget,
} }
def set_term_title(title):
if not config_var.get()["interface"]["global"]["change_window_title"]:
return
system = platform.system()
if system == "Windows":
os.system(f"title {title}")
else: # Linux, Mac, etc.
os.write(2, f"\033]2;{title}\007".encode("utf-8"))
@@ -1,5 +1,6 @@
from typing import Iterable from typing import Iterable
from textual.app import ComposeResult
from textual.widget import Widget from textual.widget import Widget
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
@@ -14,7 +15,7 @@ class BasePuzzleWidget(Widget):
id: str | None = None, id: str | None = None,
classes: str | None = None, classes: str | None = None,
disabled: bool = False, disabled: bool = False,
markup: bool = True, markup: bool = True
) -> None: ) -> None:
super().__init__( super().__init__(
*children, *children,
@@ -22,7 +23,7 @@ class BasePuzzleWidget(Widget):
id=id, id=id,
classes=classes, classes=classes,
disabled=disabled, disabled=disabled,
markup=markup, markup=markup
) )
self.atom = atom self.atom = atom
+10 -38
View File
@@ -2,14 +2,13 @@ import copy
import random import random
from typing import TypedDict from typing import TypedDict
from textual.containers import ScrollableContainer from textual.containers import Container
from textual.message import Message
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Button, Label, Markdown from textual.widgets import Button, Label
from textual.events import Key
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz import heurams.kernel.puzzles as pz
from heurams.services.hasher import hash
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .base_puzzle_widget import BasePuzzleWidget from .base_puzzle_widget import BasePuzzleWidget
@@ -51,11 +50,10 @@ class ClozePuzzle(BasePuzzleWidget):
self.hashtable = {} self.hashtable = {}
self.alia = alia self.alia = alia
self._load() self._load()
self.btn_shortcuts = {}
self.hashmap = dict() self.hashmap = dict()
def _load(self): def _load(self):
setting = self.atom.registry["nucleon"]["puzzles"][self.alia] setting = self.atom.registry["orbital"]["puzzles"][self.alia]
self.puzzle = pz.ClozePuzzle( self.puzzle = pz.ClozePuzzle(
text=setting["text"], text=setting["text"],
delimiter=setting["delimiter"], delimiter=setting["delimiter"],
@@ -67,39 +65,20 @@ class ClozePuzzle(BasePuzzleWidget):
def compose(self): def compose(self):
yield Label(self.puzzle.wording, id="sentence") yield Label(self.puzzle.wording, id="sentence")
yield Markdown(f"> {self.listprint(self.inputlist)}", id="inputpreview") yield Label(f"当前输入: {self.inputlist}", id="inputpreview")
# 渲染当前问题的选项 # 渲染当前问题的选项
with ScrollableContainer(id="btn-container") as s: with Container(id="btn-container"):
c = 0
for i in self.ans: for i in self.ans:
h = str(hash(i)) self.hashmap[str(hash(i))] = i
if hash(i) in self.hashmap.keys(): btnid = f"sel000-{hash(i)}"
continue
c += 1
self.hashmap[h] = i
btnid = f"sel000-{h}"
logger.debug(f"建立按钮 {btnid}") logger.debug(f"建立按钮 {btnid}")
self.btn_shortcuts[f"{c}"] = btnid yield Button(i, id=f"{btnid}")
yield Button(f"[{c}] {i}", id=f"{btnid}")
s.focus()
yield Button("退格", id="delete") yield Button("退格", id="delete")
self.btn_shortcuts[f"0"] = "delete"
self.btn_shortcuts[f"backspace"] = "delete"
self.btn_shortcuts[f"delete"] = "delete"
def listprint(self, lst):
s = ""
if lst:
lastone = lst[-1]
for i in lst[:-1]:
s += i + " "
s += f" `{lastone}`"
return s
def update_display(self): def update_display(self):
preview = self.query_one("#inputpreview") preview = self.query_one("#inputpreview")
preview.update(f"> {self.listprint(self.inputlist)}") # type: ignore preview.update(f"当前输入: {self.inputlist}") # type: ignore
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id button_id = event.button.id
@@ -128,10 +107,3 @@ class ClozePuzzle(BasePuzzleWidget):
pass pass
else: else:
self.atom.minimize(rating) self.atom.minimize(rating)
def on_key(self, event: Key) -> None:
self.notify(event.key)
if event.key in self.btn_shortcuts:
btn_id = self.btn_shortcuts.get(event.key)
btn_id = "#" + btn_id
self.query_one(btn_id, Button).press()
+3 -6
View File
@@ -7,28 +7,25 @@ class Finished(Widget):
self, self,
*children: Widget, *children: Widget,
alia="", alia="",
is_saved=0,
name: str | None = None, name: str | None = None,
id: str | None = None, id: str | None = None,
classes: str | None = None, classes: str | None = None,
disabled: bool = False, disabled: bool = False,
markup: bool = True, markup: bool = True
) -> None: ) -> None:
self.alia = alia self.alia = alia
self.is_saved = is_saved
super().__init__( super().__init__(
*children, *children,
name=name, name=name,
id=id, id=id,
classes=classes, classes=classes,
disabled=disabled, disabled=disabled,
markup=markup, markup=markup
) )
def compose(self): def compose(self):
yield Label("本次记忆进程结束", id="finished_msg") yield Label("本次记忆进程结束", id="finished_msg")
yield Label(f"算法数据{'已保存' if self.is_saved else "未能保存"}") yield Button("返回上一级", id="back-to-menu")
yield Button("返回上一级", flat=True, id="back-to-menu")
def on_button_pressed(self, event): def on_button_pressed(self, event):
button_id = event.button.id button_id = event.button.id
+16 -42
View File
@@ -1,7 +1,7 @@
# 单项选择题 # 单项选择题
from typing import TypedDict from typing import TypedDict
from textual.containers import ScrollableContainer from textual.containers import Container, ScrollableContainer
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Button, Label from textual.widgets import Button, Label
@@ -9,7 +9,7 @@ import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz import heurams.kernel.puzzles as pz
from heurams.services.hasher import hash from heurams.services.hasher import hash
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from textual.events import Key
from .base_puzzle_widget import BasePuzzleWidget from .base_puzzle_widget import BasePuzzleWidget
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -51,49 +51,35 @@ class MCQPuzzle(BasePuzzleWidget):
self.hashmap = dict() self.hashmap = dict()
self.cursor = 0 self.cursor = 0
self.atom = atom self.atom = atom
self.btn_shortcuts = {}
self._load() self._load()
def _load(self): def _load(self):
cfg = self.atom.registry["nucleon"]["puzzles"][self.alia] cfg = self.atom.registry["orbital"]["puzzles"][self.alia]
if cfg["mapping"] == {}:
self.screen.rating = 5 # type: ignore
self.puzzle = pz.MCQPuzzle( self.puzzle = pz.MCQPuzzle(
cfg["mapping"], cfg["jammer"], int(cfg["max_riddles_num"]), cfg["prefix"] cfg["mapping"], cfg["jammer"], int(cfg["max_riddles_num"]), cfg["prefix"]
) )
self.puzzle.refresh() self.puzzle.refresh()
def compose(self): def compose(self):
setting: Setting = self.atom.registry["nucleon"]["puzzles"][self.alia] self.atom.registry["nucleon"].do_eval()
if len(self.inputlist) > len(self.puzzle.options): setting: Setting = self.atom.registry["nucleon"].metadata["orbital"]["puzzles"][
logger.debug("ERR IDX") self.alia
logger.debug(self.inputlist) ]
logger.debug(self.puzzle.options) logger.debug(f"Puzzle Setting: {setting}")
else:
current_options = self.puzzle.options[len(self.inputlist)] current_options = self.puzzle.options[len(self.inputlist)]
yield Label(setting["primary"], id="sentence") yield Label(setting["primary"], id="sentence")
yield Label(self.puzzle.wording[len(self.inputlist)], id="puzzle") yield Label(self.puzzle.wording[len(self.inputlist)], id="puzzle")
yield Label(f"当前输入: {self.inputlist}", id="inputpreview") yield Label(f"当前输入: {self.inputlist}", id="inputpreview")
# 渲染当前问题的选项 # 渲染当前问题的选项
c = 0 with Container(id="btn-container"):
with ScrollableContainer(id="btn-container") as s:
for i in current_options: for i in current_options:
if i in [" ", ""]: self.hashmap[str(hash(i))] = i
continue btnid = f"sel{str(self.cursor).zfill(3)}-{hash(i)}"
c += 1
h = str(hash(i))
self.hashmap[h] = i
btnid = f"sel{str(self.cursor).zfill(3)}-{h}"
logger.debug(f"建立按钮 {btnid}") logger.debug(f"建立按钮 {btnid}")
self.btn_shortcuts[f"{c}"] = f"{btnid}" yield Button(i, id=f"{btnid}")
yield Button(f"[{c}] " + i, id=f"{btnid}")
s.focus()
yield Button("退格", id="delete")
self.btn_shortcuts["0"] = f"delete" yield Button("退格", id="delete")
self.btn_shortcuts["delete"] = f"delete"
self.btn_shortcuts["backspace"] = f"delete"
def update_display(self, error=0): def update_display(self, error=0):
# 更新预览标签 # 更新预览标签
@@ -151,25 +137,20 @@ class MCQPuzzle(BasePuzzleWidget):
for child in container.children for child in container.children
if hasattr(child, "id") and child.id and child.id.startswith("sel") if hasattr(child, "id") and child.id and child.id.startswith("sel")
] ]
container.focus()
for button in buttons_to_remove: for button in buttons_to_remove:
logger.info(button) logger.info(button)
container.remove_children("#" + button.id) # type: ignore container.remove_children("#" + button.id) # type: ignore
# 添加当前题目的选项按钮 # 添加当前题目的选项按钮
c = 0
current_question_index = len(self.inputlist) current_question_index = len(self.inputlist)
if current_question_index < len(self.puzzle.options): if current_question_index < len(self.puzzle.options):
current_options = self.puzzle.options[current_question_index] current_options = self.puzzle.options[current_question_index]
for option in current_options: for option in current_options:
if option in ["", " "]:
continue
c += 1
button_id = f"sel{str(self.cursor).zfill(3)}-{hash(option)}" button_id = f"sel{str(self.cursor).zfill(3)}-{hash(option)}"
if button_id not in self.hashmap: if button_id not in self.hashmap:
self.hashmap[button_id[7:]] = option self.hashmap[button_id] = option
new_button = Button(f"[{c}] " + option, id=button_id) new_button = Button(option, id=button_id)
self.btn_shortcuts[f"{c}"] = button_id
container.mount(new_button) container.mount(new_button)
def handler(self, rating): def handler(self, rating):
@@ -177,10 +158,3 @@ class MCQPuzzle(BasePuzzleWidget):
pass pass
else: else:
self.atom.minimize(rating) self.atom.minimize(rating)
def on_key(self, event: Key) -> None:
self.notify(event.key)
if event.key in self.btn_shortcuts:
btn_id = self.btn_shortcuts.get(event.key)
btn_id = "#" + btn_id
self.query_one(btn_id, Button).press()
+2 -2
View File
@@ -11,7 +11,7 @@ class Placeholder(Widget):
id: str | None = None, id: str | None = None,
classes: str | None = None, classes: str | None = None,
disabled: bool = False, disabled: bool = False,
markup: bool = True, markup: bool = True
) -> None: ) -> None:
super().__init__( super().__init__(
*children, *children,
@@ -19,7 +19,7 @@ class Placeholder(Widget):
id=id, id=id,
classes=classes, classes=classes,
disabled=disabled, disabled=disabled,
markup=markup, markup=markup
) )
def compose(self): def compose(self):
+21 -14
View File
@@ -2,6 +2,8 @@ import re
from typing import Dict, List, TypedDict from typing import Dict, List, TypedDict
from textual.containers import Center from textual.containers import Center
from textual.message import Message
from textual.reactive import reactive
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Button, Label, Markdown, Static from textual.widgets import Button, Label, Markdown, Static
@@ -47,13 +49,8 @@ class Recognition(BasePuzzleWidget):
self.alia = alia self.alia = alia
def compose(self): def compose(self):
from heurams.context import config_var cfg: RecognitionConfig = self.atom.registry["orbital"]["puzzles"][self.alia]
delim = self.atom.registry["nucleon"].metadata["formation"]["delimiter"]
autovoice = config_var.get()["interface"]["widgets"]["recognition"]["autovoice"]
if autovoice:
self.screen.action_play_voice() # type: ignore
cfg: RecognitionConfig = self.atom.registry["nucleon"]["puzzles"][self.alia]
delim = self.atom.registry["nucleon"]["delimiter"]
replace_dict = { replace_dict = {
", ": ",", ", ": ",",
". ": ".", ". ": ".",
@@ -66,11 +63,12 @@ class Recognition(BasePuzzleWidget):
f":{delim}": ":", f":{delim}": ":",
} }
nucleon = self.atom.registry["nucleon"]
metadata = self.atom.registry["nucleon"].metadata
primary = cfg["primary"] primary = cfg["primary"]
with Center(): with Center():
for i in cfg["top_dim"]: yield Static(f"[dim]{cfg['top_dim']}[/]")
yield Static(f"[dim]{i}[/]")
yield Label("") yield Label("")
for old, new in replace_dict.items(): for old, new in replace_dict.items():
@@ -86,21 +84,30 @@ class Recognition(BasePuzzleWidget):
for item in cfg["secondary"]: for item in cfg["secondary"]:
if isinstance(item, list): if isinstance(item, list):
for j in item: for j in item:
yield Markdown(f"### 笔记: {j}") # TODO ANNOTATION yield Markdown(f"### {metadata['annotation'][item]}: {j}")
continue continue
if isinstance(item, Dict): if isinstance(item, Dict):
total = "" total = ""
for j, k in item.items(): # type: ignore for j, k in item.items(): # type: ignore
total += f"> {j}: {k} \n" total += f"> **{j}**: {k} \n"
yield Markdown(total) yield Markdown(total)
if isinstance(item, str): if isinstance(item, str):
yield Markdown(item) yield Markdown(item)
with Center() as c: with Center():
with Button("我已知晓", id="ok") as b: yield Button("我已知晓", id="ok")
b.focus()
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok": if event.button.id == "ok":
self.screen.rating = 5 # type: ignore self.screen.rating = 5 # type: ignore
self.handler(5) self.handler(5)
def handler(self, rating):
if not self.atom.registry["runtime"]["locked"]:
if not self.atom.registry["electron"].is_activated():
self.atom.registry["electron"].activate()
logger.debug(f"激活原子 {self.atom}")
self.atom.lock(1)
self.atom.minimize(5)
else:
pass
-1
View File
@@ -1,3 +1,2 @@
# Kernel - HeurAMS 核心 # Kernel - HeurAMS 核心
记忆规划相关算法与数据结构, 可脱离业务层 记忆规划相关算法与数据结构, 可脱离业务层
+7 -10
View File
@@ -1,21 +1,18 @@
from .base import BaseAlgorithm from heurams.services.logger import get_logger
from .sm2 import SM2Algorithm from .sm2 import SM2Algorithm
from .sm15m import SM15MAlgorithm from .sm15m import SM15MAlgorithm
from .nsp0 import NSP0Algorithm
from .fsrs import FSRSAlgorithm logger = get_logger(__name__)
__all__ = [ __all__ = [
"SM2Algorithm", "SM2Algorithm",
"BaseAlgorithm",
"SM15MAlgorithm",
"NSP0Algorithm",
"FSRSAlgorithm",
] ]
algorithms = { algorithms = {
"SM-2": SM2Algorithm, "SM-2": SM2Algorithm,
"NSP-0": NSP0Algorithm,
"SM-15M": SM15MAlgorithm, "SM-15M": SM15MAlgorithm,
"FSRS": FSRSAlgorithm, # "SM-15M": SM15MAlgorithm,
"Base": BaseAlgorithm,
} }
logger.debug("算法模块初始化完成, 注册的算法: %s", list(algorithms.keys()))
+3 -10
View File
@@ -8,9 +8,9 @@ logger = get_logger(__name__)
class BaseAlgorithm: class BaseAlgorithm:
algo_name = "BaseAlgorithm" algo_name = "BaseAlgorithm"
desc = "算法基类"
class AlgodataDict(TypedDict): class AlgodataDict(TypedDict):
efactor: float
real_rept: int real_rept: int
rept: int rept: int
interval: int interval: int
@@ -40,6 +40,7 @@ class BaseAlgorithm:
feedback, feedback,
is_new_activation, is_new_activation,
) )
pass
@classmethod @classmethod
def is_due(cls, algodata) -> int: def is_due(cls, algodata) -> int:
@@ -51,7 +52,7 @@ class BaseAlgorithm:
return 1 return 1
@classmethod @classmethod
def get_rating(cls, algodata) -> str: def rate(cls, algodata) -> str:
"""获取评分信息""" """获取评分信息"""
logger.debug( logger.debug(
"BaseAlgorithm.rate 被调用, algodata keys: %s", "BaseAlgorithm.rate 被调用, algodata keys: %s",
@@ -67,11 +68,3 @@ class BaseAlgorithm:
list(algodata.keys()) if algodata else [], list(algodata.keys()) if algodata else [],
) )
return -1 return -1
@classmethod
def check_integrity(cls, algodata):
try:
cls.AlgodataDict(**algodata[cls.algo_name])
return 1
except:
return 0
+2 -242
View File
@@ -1,246 +1,6 @@
""" # FSRS 算法模块, 尚未就绪
FSRS 算法模块 — 基于 py-fsrs 的现代间隔重复调度器
基于: https://github.com/open-spaced-repetition/py-fsrs
"""
import json
import os
import pathlib
from datetime import datetime, timezone, timedelta
from typing import TypedDict
from fsrs import Scheduler, Card, Rating
from heurams.context import config_var
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from heurams.services.timer import get_daystamp, get_timestamp
from .base import BaseAlgorithm
logger = get_logger(__name__) logger = get_logger(__name__)
# 全局 Scheduler 状态文件路径 logger.info("FSRS算法模块尚未实现")
_SCHEDULER_STATE_FILE = pathlib.Path(
config_var.get()["global"]["paths"]["data"]
) / "global" / "fsrs_scheduler_state.json"
def _get_global_scheduler():
"""获取全局 FSRS Scheduler 实例,从文件加载或创建新的"""
if os.path.exists(_SCHEDULER_STATE_FILE):
try:
with open(_SCHEDULER_STATE_FILE, "r", encoding="utf-8") as f:
return Scheduler.from_json(f.read())
except Exception:
logger.warning("FSRS Scheduler 状态文件加载失败,创建新实例")
return Scheduler()
def _save_global_scheduler(scheduler):
"""保存全局 FSRS Scheduler 实例到文件"""
try:
_SCHEDULER_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
data = scheduler.to_json()
with open(_SCHEDULER_STATE_FILE, "w", encoding="utf-8") as f:
f.write(data)
except Exception:
logger.exception("FSRS Scheduler 状态保存失败")
def _feedback_to_rating(feedback: int) -> Rating:
"""将 SM-2 风格 feedback (0-5) 映射为 FSRS Rating (1-4)"""
if feedback <= 2:
return Rating.Again
elif feedback == 3:
return Rating.Hard
elif feedback == 4:
return Rating.Good
else:
return Rating.Easy
def _datetime_to_daystamp(dt: datetime) -> int:
"""将 datetime 转换为天数戳(从 1970-01-01"""
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
delta = dt - epoch
return delta.days
def _daystamp_to_datetime(daystamp: int) -> datetime:
"""将天数戳转换为 UTC datetime"""
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
return epoch + timedelta(days=daystamp)
class FSRSAlgorithm(BaseAlgorithm):
algo_name = "FSRS"
desc = "基于 py-fsrs 的现代间隔重复调度器"
class AlgodataDict(TypedDict):
# FSRS 特有字段
fsrs_state: int # State 枚举值: 1=Learning, 2=Review, 3=Relearning
fsrs_step: int # 当前学习步进索引, -1 表示 None (Review 状态)
fsrs_stability: float # 稳定性(秒),0.0 表示尚未计算
fsrs_difficulty: float # 难度 [1.0, 10.0], 0.0 表示尚未计算
# 标准 BaseAlgorithm 兼容字段
real_rept: int
rept: int
interval: int
last_date: int
next_date: int
is_activated: int
last_modify: float
defaults = {
"fsrs_state": 1, # State.Learning
"fsrs_step": 0,
"fsrs_stability": 0.0,
"fsrs_difficulty": 0.0,
"real_rept": 0,
"rept": 0,
"interval": 0,
"last_date": 0,
"next_date": 0,
"is_activated": 0,
"last_modify": get_timestamp(),
}
@classmethod
def _algodata_to_card(cls, algodata):
"""从 algodata 恢复 Card 实例"""
data = algodata.get(cls.algo_name, cls.defaults.copy())
card = Card()
# State: int → IntEnum
card.state = data.get("fsrs_state", 1)
# Step: -1 表示 NoneReview 状态下的 card.step 为 None
step = data.get("fsrs_step", -1)
card.step = None if step == -1 else step
# Stability: 0.0 表示尚未计算(新卡片)
stability = data.get("fsrs_stability", 0.0)
card.stability = None if stability == 0.0 else stability
# Difficulty: 0.0 表示尚未计算
difficulty = data.get("fsrs_difficulty", 0.0)
card.difficulty = None if difficulty == 0.0 else difficulty
# due: 新卡片(next_date ≤ 0)设为当前时间
next_date = data.get("next_date", 0)
if next_date <= 0:
card.due = datetime.now(timezone.utc)
else:
card.due = _daystamp_to_datetime(next_date)
# last_review
last_date = data.get("last_date", 0)
card.last_review = (
_daystamp_to_datetime(last_date) if last_date > 0 else None
)
return card
@classmethod
def _card_to_algodata(cls, card, algodata):
"""将 Card 实例状态写回 algodata"""
if cls.algo_name not in algodata:
algodata[cls.algo_name] = cls.defaults.copy()
data = algodata[cls.algo_name]
data["fsrs_state"] = int(card.state)
data["fsrs_step"] = card.step if card.step is not None else -1
data["fsrs_stability"] = card.stability if card.stability is not None else 0.0
data["fsrs_difficulty"] = (
card.difficulty if card.difficulty is not None else 0.0
)
data["last_date"] = (
_datetime_to_daystamp(card.last_review)
if card.last_review
else data.get("last_date", 0)
)
data["next_date"] = (
_datetime_to_daystamp(card.due) if card.due else 0
)
data["interval"] = max(0, data["next_date"] - data["last_date"])
data["last_modify"] = get_timestamp()
return algodata
@classmethod
def revisor(
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
):
"""FSRS 算法迭代决策机制实现
将 feedback (0-5) 映射为 FSRS Rating 后交由 py-fsrs 调度器处理。
Args:
feedback (int): 0-5 的记忆保留率量化参数
is_new_activation: 是否为全新激活(重置为初始状态)
"""
logger.debug(
"FSRS.revisor 开始, feedback: %d, is_new_activation: %s",
feedback,
is_new_activation,
)
if feedback == -1:
logger.debug("feedback 为 -1, 跳过更新")
return
scheduler = _get_global_scheduler()
rating = _feedback_to_rating(feedback)
if is_new_activation:
card = Card()
logger.debug("新激活,创建新 Card")
else:
card = cls._algodata_to_card(algodata)
card, review_log = scheduler.review_card(card, rating)
_save_global_scheduler(scheduler)
cls._card_to_algodata(card, algodata)
# real_rept: 总复习次数
algodata[cls.algo_name]["real_rept"] += 1
# rept: 成功回忆次数(feedback ≥ 3 视为成功)
if feedback >= 3:
algodata[cls.algo_name]["rept"] += 1
logger.debug(
"FSRS.revisor 完成: stability=%s, difficulty=%s, state=%s, "
"next_date=%d",
card.stability,
card.difficulty,
card.state,
algodata[cls.algo_name]["next_date"],
)
@classmethod
def is_due(cls, algodata):
data = algodata.get(cls.algo_name, cls.defaults.copy())
next_date = data.get("next_date", 0)
current = get_daystamp()
result = next_date <= current
logger.debug(
"FSRS.is_due: next_date=%d, current=%d, result=%s",
next_date,
current,
result,
)
return result
@classmethod
def get_rating(cls, algodata):
data = algodata.get(cls.algo_name, cls.defaults.copy())
difficulty = data.get("fsrs_difficulty", 0.0)
logger.debug("FSRS.get_rating: difficulty=%f", difficulty)
return str(difficulty)
@classmethod
def nextdate(cls, algodata) -> int:
data = algodata.get(cls.algo_name, cls.defaults.copy())
next_date = data.get("next_date", 0)
logger.debug("FSRS.nextdate: %d", next_date)
return next_date
-94
View File
@@ -1,94 +0,0 @@
from typing import TypedDict
import heurams.services.timer as timer
from heurams.services.logger import get_logger
from .base import BaseAlgorithm
logger = get_logger(__name__)
class NSP0Algorithm(BaseAlgorithm):
algo_name = "NSP-0"
desc = "快速筛选用特殊调度器"
class AlgodataDict(TypedDict):
real_rept: int
rept: int
interval: int
important: int
last_date: int
next_date: int
is_activated: int
last_modify: float
defaults = {
"real_rept": 0,
"important": 0,
"rept": 0,
"interval": 0,
"last_date": 0,
"next_date": 0,
"is_activated": 0,
"last_modify": timer.get_timestamp(),
}
@classmethod
def revisor(
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
):
"""NSP-0 算法迭代决策机制实现
根据 quality(0 ~ 5) 进行参数迭代最佳间隔
quality 由主程序评估
Args:
quality (int): 记忆保留率量化参数
"""
logger.debug(
"NSP0.revisor 开始, feedback: %d, is_new_activation: %s",
feedback,
is_new_activation,
)
if feedback == -1:
logger.debug("feedback 为 -1, 跳过更新")
return
algodata[cls.algo_name]["interval"] = 1 if feedback <= 3 else float("inf")
algodata[cls.algo_name]["important"] = (
1 if feedback <= 3 else algodata[cls.algo_name]["important"]
)
algodata[cls.algo_name]["last_date"] = timer.get_daystamp()
algodata[cls.algo_name]["next_date"] = (
timer.get_daystamp() + algodata[cls.algo_name]["interval"]
)
algodata[cls.algo_name]["last_modify"] = timer.get_timestamp()
logger.debug(
"更新日期: last_date=%d, next_date=%d, last_modify=%f",
algodata[cls.algo_name]["last_date"],
algodata[cls.algo_name]["next_date"],
algodata[cls.algo_name]["last_modify"],
)
@classmethod
def is_due(cls, algodata):
result = algodata[cls.algo_name]["next_date"] <= timer.get_daystamp()
logger.debug(
"NSP0.is_due: next_date=%d, current_daystamp=%d, result=%s",
algodata[cls.algo_name]["next_date"],
timer.get_daystamp(),
result,
)
return result
@classmethod
def get_rating(cls, algodata):
efactor = algodata[cls.algo_name]["efactor"]
logger.debug("NSP0.rate: efactor=%f", efactor)
return str(efactor)
@classmethod
def nextdate(cls, algodata) -> int:
next_date = algodata[cls.algo_name]["next_date"]
logger.debug("NSP0.nextdate: %d", next_date)
return next_date
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -10,7 +10,6 @@ logger = get_logger(__name__)
class SM2Algorithm(BaseAlgorithm): class SM2Algorithm(BaseAlgorithm):
algo_name = "SM-2" algo_name = "SM-2"
desc = "经典间隔重复算法"
class AlgodataDict(TypedDict): class AlgodataDict(TypedDict):
efactor: float efactor: float
@@ -117,7 +116,7 @@ class SM2Algorithm(BaseAlgorithm):
return result return result
@classmethod @classmethod
def get_rating(cls, algodata): def rate(cls, algodata):
efactor = algodata[cls.algo_name]["efactor"] efactor = algodata[cls.algo_name]["efactor"]
logger.debug("SM2.rate: efactor=%f", efactor) logger.debug("SM2.rate: efactor=%f", efactor)
return str(efactor) return str(efactor)
-4
View File
@@ -1,4 +0,0 @@
from .evalizor import Evalizer
from .lict import Lict
__all__ = ["Evalizer", "Lict"]
-33
View File
@@ -1,33 +0,0 @@
class Evalizer:
"""几乎无副作用的模板系统
接受环境信息并创建一个模板解析工具, 工具传入参数支持list, dict及其嵌套
副作用问题: 仅存在于 eval 函数
"""
# TODO: 弃用风险极高的 eval
# TODO: 异步/多线程执行避免堵塞
def __init__(self, environment: dict) -> None:
self.env = environment
def __call__(self, anyobj):
return self.travel(anyobj)
def travel(self, anyobj):
if isinstance(anyobj, list):
return list(map(self.travel, anyobj))
elif isinstance(anyobj, dict):
return dict(map(self.travel, anyobj.items()))
elif isinstance(anyobj, tuple):
return tuple(map(self.travel, anyobj))
elif isinstance(anyobj, str):
if anyobj.startswith("eval:"):
return self.eval_with_env(anyobj[5:])
else:
return anyobj
else:
return anyobj
def eval_with_env(self, s: str):
ret = eval(s, globals(), self.env)
return ret
-173
View File
@@ -1,173 +0,0 @@
from collections.abc import MutableSequence
from typing import Any, Optional
class Lict(MutableSequence):
""" "列典" 对象
同时兼容字典和列表大多数 API, 两边数据同步的容器, 性能与 list/dict 大体相当
列表数据是 dictobj.items() 的格式
支持根据字典或列表初始化
限制要求:
- 键名一定唯一, 且仅能为字符串
- append 的元组中, 表示键名的元素不能重复, 否则会导致覆盖行为
"""
def __init__(
self,
initlist: Optional[list] = None,
initdict: Optional[dict] = None,
forced_order: bool = False,
):
self._dict = {}
self._list = []
self._dict_dirty = False
self._list_dirty = False
self.forced_order = forced_order
if initdict: # initdict 更优先
self._dict = initdict.copy()
self._list_dirty = True
elif initlist:
self._list = initlist.copy()
self._dict_dirty = True
self._sync_if_needed()
def _sync_if_needed(self):
"""只在需要时同步"""
if self._dict_dirty:
self._dict = {k: v for k, v in self._list}
if self.forced_order:
self._list.sort()
self._dict_dirty = False
if self._list_dirty:
self._list = list(self._dict.items())
if self.forced_order:
self._list.sort()
self._list_dirty = False
def __getitem__(self, key):
if isinstance(key, str):
return self._dict[key]
else:
self._sync_if_needed()
return self._list[key]
def __setitem__(self, key, value):
"""传入键值对时等同于操作字典, 传入索引+元组时等用于替换某索引的列表值为新元组"""
if isinstance(key, str):
self._dict[key] = value
self._list_dirty = True
else:
if value != (value[0], value[1]):
raise NotImplementedError
self._sync_if_needed()
old_key = self._list[key][0]
self._dict.pop(old_key)
self._list[key] = value
self._dict[value[0]] = value[1] # 避免全量同步
def __delitem__(self, key):
if isinstance(key, str): # 字符串键
del self._dict[key]
self._list_dirty = True
else: # 数字索引
self._sync_if_needed()
del_key = self._list[key][0]
del self._list[key]
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
def __len__(self):
self._sync_if_needed()
return len(self._list)
def __iter__(self):
self._sync_if_needed()
return iter(self._list)
def __contains__(self, item):
self._sync_if_needed()
return item in self._list or item in self.keys() or item in self.values()
def append(self, item):
if item != (item[0], item[1]):
raise NotImplementedError
self._sync_if_needed() # 以防 forced_order
key, value = item
self._dict[key] = value
self._list.append(item) # 两端都已同步
self._sync_if_needed() # 以防 forced_order
def append_if_it_doesnt_exist_before(self, item: Any):
if item != (item[0], item[1]):
raise NotImplementedError
self._sync_if_needed()
if item[0] not in self:
self.append(item)
self._sync_if_needed()
def insert(self, i: int, item: Any) -> None:
if item != (item[0], item[1]):
raise NotImplementedError
self._sync_if_needed()
self._list.insert(i, item)
self._dict_dirty = True
def pop(self, i: int = -1) -> Any:
self._sync_if_needed()
res = self._list.pop(i)
self._dict_dirty = True
return res
def remove(self, item: Any) -> None:
if isinstance(item, str):
item = (item, self._dict[item])
if item != (item[0], item[1]):
raise NotImplementedError
self._sync_if_needed()
self._list.remove(item)
self._dict_dirty = True
def clear(self) -> None:
self._dict.clear()
self._list.clear()
self._dict_dirty = False
self._list_dirty = False
def index(self):
raise NotImplementedError
def extend(self):
raise NotImplementedError
def sort(self):
raise NotImplementedError
def reverse(self):
raise NotImplementedError
def get_itemic_unit(self, ident):
return (ident, self._dict[ident])
def keys_equal_with(self, other):
self._sync_if_needed()
return self.key_equality(self, other)
@classmethod
def key_equality(cls, a, b):
return a.keys() == b.keys()
+23 -15
View File
@@ -1,21 +1,29 @@
from .atom import Atom """
from .electron import Electron Particle 模块 - 粒子对象系统
from .nucleon import Nucleon
from .placeholders import (
AtomPlaceholder,
ElectronPlaceholder,
NucleonPlaceholder,
orbital_placeholder,
)
# from .orbital import Orbital 提供闪卡所需对象, 使用物理学粒子的领域驱动设计
"""
from heurams.services.logger import get_logger
logger = get_logger(__name__)
logger.debug("粒子模块已加载")
from .atom import Atom, atom_registry
from .electron import Electron
from .loader import load_electron, load_nucleon
from .nucleon import Nucleon
from .orbital import Orbital
from .probe import probe_all, probe_by_filename
__all__ = [ __all__ = [
"Atom",
"Electron", "Electron",
"Nucleon", "Nucleon",
"AtomPlaceholder", "Orbital",
"NucleonPlaceholder", "Atom",
"ElectronPlaceholder", "probe_all",
"orbital_placeholder", "probe_by_filename",
"load_nucleon",
"load_electron",
"atom_registry",
] ]
+169 -28
View File
@@ -1,9 +1,17 @@
import json
import pathlib
import typing
from typing import TypedDict from typing import TypedDict
import bidict
import toml
from heurams.context import config_var
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .electron import Electron from .electron import Electron
from .nucleon import Nucleon from .nucleon import Nucleon
from .orbital import Orbital
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -11,13 +19,19 @@ logger = get_logger(__name__)
class AtomRegister_runtime(TypedDict): class AtomRegister_runtime(TypedDict):
locked: bool # 只读锁定标识符 locked: bool # 只读锁定标识符
min_rate: int # 最低评分 min_rate: int # 最低评分
new_activation: bool # 新激活 newact: bool # 新激活
class AtomRegister(TypedDict): class AtomRegister(TypedDict):
nucleon: Nucleon nucleon: Nucleon
nucleon_path: pathlib.Path
nucleon_fmt: str
electron: Electron electron: Electron
orbital: dict electron_path: pathlib.Path
electron_fmt: str
orbital: Orbital
orbital_path: pathlib.Path
orbital_fmt: str
runtime: AtomRegister_runtime runtime: AtomRegister_runtime
@@ -30,27 +44,39 @@ class Atom:
以及关联路径 以及关联路径
""" """
default_runtime = { def __init__(self, ident=""):
"locked": False, logger.debug("创建 Atom 实例, ident: '%s'", ident)
"min_rate": float("inf"), self.ident = ident
"new_activation": False, atom_registry[ident] = self
} logger.debug("Atom 已注册到全局注册表, 当前注册表大小: %d", len(atom_registry))
# self.is_evaled = False
def __init__(self, nucleon_obj=None, electron_obj=None, orbital_obj=None):
self.ident = nucleon_obj["ident"] # type: ignore
self.registry: AtomRegister = { # type: ignore self.registry: AtomRegister = { # type: ignore
"ident": nucleon_obj["ident"], # type: ignore "nucleon": None,
"nucleon": nucleon_obj, "nucleon_path": None,
"electron": electron_obj, "nucleon_fmt": "toml",
"orbital": orbital_obj, "electron": None,
"runtime": dict(), "electron_path": None,
"electron_fmt": "json",
"orbital": None,
"orbital_path": None, # 允许设置为 None, 此时使用 nucleon 文件内的推荐配置
"orbital_fmt": "toml",
"runtime": {"locked": False, "min_rate": 0x3F3F3F3F, "newact": False},
} }
self.init_runtime() self.do_eval()
if self.registry["electron"].is_activated() == 0: logger.debug("Atom 初始化完成")
self.registry["runtime"]["new_activation"] = True
def init_runtime(self): def link(self, key, value):
self.registry["runtime"] = AtomRegister_runtime(**self.default_runtime) logger.debug("Atom.link: key='%s', value type: %s", key, type(value).__name__)
if key in self.registry.keys():
self.registry[key] = value
logger.debug("'%s' 已链接, 触发 do_eval", key)
self.do_eval()
if key == "electron":
if self.registry["electron"].is_activated() == 0:
self.registry["runtime"]["newact"] = True
else:
logger.error("尝试链接不受支持的键: '%s'", key)
raise ValueError("不受支持的原子元数据链接操作")
def minimize(self, rating): def minimize(self, rating):
"""效果等同于 self.registry['runtime']['min_rate'] = min(rating, self.registry['runtime']['min_rate']) """效果等同于 self.registry['runtime']['min_rate'] = min(rating, self.registry['runtime']['min_rate'])
@@ -83,21 +109,136 @@ class Atom:
logger.debug(f"允许总评分: {self.registry['runtime']['min_rate']}") logger.debug(f"允许总评分: {self.registry['runtime']['min_rate']}")
self.registry["electron"].revisor( self.registry["electron"].revisor(
self.registry["runtime"]["min_rate"], self.registry["runtime"]["min_rate"],
is_new_activation=self.registry["runtime"]["new_activation"], is_new_activation=self.registry["runtime"]["newact"],
) )
else: else:
logger.debug("禁止总评分") logger.debug("禁止总评分")
def do_eval(self):
"""
执行并以结果替换当前单元的所有 eval 语句
TODO: 带有限制的 eval, 异步/多线程执行避免堵塞
"""
logger.debug("Atom.do_eval 开始")
# eval 环境设置
def eval_with_env(s: str):
# 初始化默认值
nucleon = self.registry["nucleon"]
default = {}
metadata = {}
try:
default = config_var.get()["puzzles"]
metadata = nucleon.metadata
except Exception:
# 如果无法获取配置或元数据, 使用空字典
logger.debug("无法获取配置或元数据, 使用空字典")
pass
try:
eval_value = eval(s)
if isinstance(eval_value, (list, dict)):
ret = eval_value
else:
ret = str(eval_value)
logger.debug(
"eval 执行成功: '%s' -> '%s'",
s,
str(ret)[:50] + "..." if len(ret) > 50 else ret,
)
except Exception as e:
ret = f"此 eval 实例发生错误: {e}"
logger.warning("eval 执行错误: '%s' -> %s", s, e)
return ret
def traverse(data, modifier):
if isinstance(data, dict):
for key, value in data.items():
data[key] = traverse(value, modifier)
return data
elif isinstance(data, list):
for i, item in enumerate(data):
data[i] = traverse(item, modifier)
return data
elif isinstance(data, tuple):
return tuple(traverse(item, modifier) for item in data)
else:
if isinstance(data, str):
if data.startswith("eval:"):
logger.debug("发现 eval 表达式: '%s'", data[5:])
return modifier(data[5:])
return data
# 如果 nucleon 存在且有 do_eval 方法, 调用它
nucleon = self.registry["nucleon"]
if nucleon is not None and hasattr(nucleon, "do_eval"):
nucleon.do_eval()
logger.debug("已调用 nucleon.do_eval")
# 如果 electron 存在且其 algodata 包含 eval 字符串, 遍历它
electron = self.registry["electron"]
if electron is not None and hasattr(electron, "algodata"):
traverse(electron.algodata, eval_with_env)
logger.debug("已处理 electron algodata eval")
# 如果 orbital 存在且是字典, 遍历它
orbital = self.registry["orbital"]
if orbital is not None and isinstance(orbital, dict):
traverse(orbital, eval_with_env)
logger.debug("orbital eval 完成")
logger.debug("Atom.do_eval 完成")
def persist(self, key):
logger.debug("Atom.persist: key='%s'", key)
path: pathlib.Path | None = self.registry[key + "_path"]
if isinstance(path, pathlib.Path):
path = typing.cast(pathlib.Path, path)
logger.debug("持久化路径: %s, 格式: %s", path, self.registry[key + "_fmt"])
path.parent.mkdir(parents=True, exist_ok=True)
if self.registry[key + "_fmt"] == "toml":
with open(path, "r+") as f:
f.seek(0)
f.truncate()
toml.dump(self.registry[key], f)
logger.debug("TOML 数据已保存到: %s", path)
elif self.registry[key + "_fmt"] == "json":
with open(path, "r+") as f:
origin = json.load(f)
f.seek(0)
f.truncate()
origin[self.ident] = self.registry[key].algodata
json.dump(origin, f, indent=2, ensure_ascii=False)
logger.debug("JSON 数据已保存到: %s", path)
else:
logger.error("不受支持的持久化格式: %s", self.registry[key + "_fmt"])
raise KeyError("不受支持的持久化格式")
else:
logger.error("路径未初始化: %s_path", key)
raise TypeError("对未初始化的路径对象操作")
def __getitem__(self, key): def __getitem__(self, key):
return self.registry[key] logger.debug("Atom.__getitem__: key='%s'", key)
if key in self.registry:
value = self.registry[key]
logger.debug("返回 value type: %s", type(value).__name__)
return value
logger.error("不支持的键: '%s'", key)
raise KeyError(f"不支持的键: {key}")
def __setitem__(self, key, value): def __setitem__(self, key, value):
if key == "ident": logger.debug(
raise AttributeError("应为只读") "Atom.__setitem__: key='%s', value type: %s", key, type(value).__name__
)
if key in self.registry:
self.registry[key] = value self.registry[key] = value
logger.debug("'%s' 已设置", key)
else:
logger.error("不支持的键: '%s'", key)
raise KeyError(f"不支持的键: {key}")
def __repr__(self): @staticmethod
from pprint import pformat def placeholder():
return (Electron.placeholder(), Nucleon.placeholder(), {})
s = pformat(self.registry, indent=4)
return s atom_registry: bidict.bidict[str, Atom] = bidict.bidict()
+74 -43
View File
@@ -1,7 +1,5 @@
from copy import deepcopy
import heurams.kernel.algorithms as algolib
import heurams.services.timer as timer import heurams.services.timer as timer
from heurams.context import config_var
from heurams.kernel.algorithms import algorithms from heurams.kernel.algorithms import algorithms
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
@@ -9,76 +7,87 @@ logger = get_logger(__name__)
class Electron: class Electron:
"""电子: 单算法支持的记忆数据包装""" """电子: 记忆分析元数据及算法"""
def __init__(self, ident: str, algodata: dict = {}, algo_name: str = ""): def __init__(self, ident: str, algodata: dict = {}, algo_name: str = "SM-2"):
"""初始化电子对象 (记忆数据) """初始化电子对象 (记忆数据)
Args: Args:
ident: 算法的唯一标识符, 用于区分不同的算法实例, 使用 algodata[ident] 获取 ident: 算法的唯一标识符, 用于区分不同的算法实例, 使用 algodata[ident] 获取
algodata: 算法数据字典引用, 包含算法的各项参数和设置 algodata: 算法数据字典, 包含算法的各项参数和设置
algo_name: 使用的算法模块标识 algo: 使用的算法模块标识
""" """
if algo_name == "": logger.debug(
algo_name = "SM-2" "创建 Electron 实例, ident: '%s', algo_name: '%s'", ident, algo_name
)
self.algodata = algodata self.algodata = algodata
self.ident = ident self.ident = ident
self.algoname = algo_name self.algo = algorithms[algo_name]
self.algo: algolib.BaseAlgorithm = algorithms[algo_name] logger.debug("使用的算法类: %s", self.algo.__name__)
if not self.algo.check_integrity(self.algodata): if self.algo not in self.algodata.keys():
self.algodata[self.algo.algo_name] = deepcopy(self.algo.defaults) self.algodata[self.algo.algo_name] = {}
logger.debug("算法键 '%s' 不存在, 已创建空字典", self.algo)
if not self.algodata[self.algo.algo_name]:
logger.debug("算法数据为空, 使用默认值初始化")
self._default_init(self.algo.defaults)
else:
logger.debug("算法数据已存在, 跳过默认初始化")
logger.debug(
"Electron 初始化完成, algodata keys: %s", list(self.algodata.keys())
)
def __repr__(self): def _default_init(self, defaults: dict):
from pprint import pformat """默认初始化包装"""
logger.debug(
s = pformat(self.algodata, indent=4) "Electron._default_init: 使用默认值, keys: %s", list(defaults.keys())
return s )
self.algodata[self.algo.algo_name] = defaults.copy()
def activate(self): def activate(self):
"""激活此电子""" """激活此电子"""
logger.debug("Electron.activate: 激活 ident='%s'", self.ident)
self.algodata[self.algo.algo_name]["is_activated"] = 1 self.algodata[self.algo.algo_name]["is_activated"] = 1
self.algodata[self.algo.algo_name]["last_modify"] = timer.get_timestamp() self.algodata[self.algo.algo_name]["last_modify"] = timer.get_timestamp()
logger.debug("电子已激活, is_activated=1")
def modify(self, key, value): def modify(self, var: str, value):
"""修改 algodata[algo] 中子字典数据""" """修改 algodata[algo] 中子字典数据"""
if key in self.algodata[self.algo.algo_name]: logger.debug("Electron.modify: var='%s', value=%s", var, value)
self.algodata[self.algo.algo_name][key] = value if var in self.algodata[self.algo.algo_name]:
self.algodata[self.algo.algo_name][var] = value
self.algodata[self.algo.algo_name]["last_modify"] = timer.get_timestamp() self.algodata[self.algo.algo_name]["last_modify"] = timer.get_timestamp()
logger.debug("变量 '%s' 已修改, 更新 last_modify", var)
else: else:
raise AttributeError("不存在的子键") logger.warning("'%s' 非已知元数据字段", var)
print(f"警告: '{var}' 非已知元数据字段")
def is_due(self): def is_due(self):
"""是否应该复习""" """是否应该复习"""
logger.debug("Electron.is_due: 检查 ident='%s'", self.ident)
result = self.algo.is_due(self.algodata) result = self.algo.is_due(self.algodata)
logger.debug("is_due 结果: %s", result)
return result and self.is_activated() return result and self.is_activated()
def rept(self, real_rept=False):
if real_rept:
return self.algodata[self.algo.algo_name]["real_rept"]
return self.algodata[self.algo.algo_name]["rept"]
def is_activated(self): def is_activated(self):
result = self.algodata[self.algo.algo_name]["is_activated"] result = self.algodata[self.algo.algo_name]["is_activated"]
logger.debug("Electron.is_activated: ident='%s', 结果: %d", self.ident, result)
return result return result
def last_modify(self): def get_rate(self):
result = self.algodata[self.algo.algo_name]["last_modify"] "评价"
return result
def get_rating(self):
try: try:
result = self.algo.get_rating(self.algodata) logger.debug("Electron.rate: ident='%s'", self.ident)
result = self.algo.rate(self.algodata)
logger.debug("rate 结果: %s", result)
return result return result
except: except:
return 0 return 0
def nextdate(self) -> int: def nextdate(self) -> int:
logger.debug("Electron.nextdate: ident='%s'", self.ident)
result = self.algo.nextdate(self.algodata) result = self.algo.nextdate(self.algodata)
return result logger.debug("nextdate 结果: %d", result)
def lastdate(self) -> int:
result = self.algodata[self.algo.algo_name]["lastdate"]
return result return result
def revisor(self, quality: int = 5, is_new_activation: bool = False): def revisor(self, quality: int = 5, is_new_activation: bool = False):
@@ -88,7 +97,32 @@ class Electron:
quality (int): 记忆保留率量化参数 (0-5) quality (int): 记忆保留率量化参数 (0-5)
is_new_activation (bool): 是否为初次激活 is_new_activation (bool): 是否为初次激活
""" """
logger.debug(
"Electron.revisor: ident='%s', quality=%d, is_new_activation=%s",
self.ident,
quality,
is_new_activation,
)
self.algo.revisor(self.algodata, quality, is_new_activation) self.algo.revisor(self.algodata, quality, is_new_activation)
logger.debug(
"revisor 完成, 更新后的 algodata: %s", self.algodata.get(self.algo, {})
)
def __str__(self):
return (
f"记忆单元预览 \n"
f"标识符: '{self.ident}' \n"
f"算法: {self.algo} \n"
f"易度系数: {self.algodata[self.algo.algo_name]['efactor']:.2f} \n"
f"已经重复的次数: {self.algodata[self.algo.algo_name]['rept']} \n"
f"下次间隔: {self.algodata[self.algo.algo_name]['interval']}\n"
f"下次复习日期时间戳: {self.algodata[self.algo.algo_name]['next_date']}"
)
def __eq__(self, other):
if self.ident == other.ident:
return True
return False
def __hash__(self): def __hash__(self):
return hash(self.ident) return hash(self.ident)
@@ -112,9 +146,6 @@ class Electron:
return len(self.algodata[self.algo.algo_name]) return len(self.algodata[self.algo.algo_name])
@staticmethod @staticmethod
def from_data(electronic_data: tuple, algo_name: str = ""): def placeholder():
_data = electronic_data """生成一个电子占位符"""
ident = _data[0] return Electron("电子对象样例内容", {})
algodata = _data[1]
ident = ident
return Electron(ident, algodata, algo_name)
+74
View File
@@ -0,0 +1,74 @@
import json
import pathlib
from copy import deepcopy
import toml
import heurams.services.hasher as hasher
from heurams.services.logger import get_logger
from .electron import Electron
from .nucleon import Nucleon
logger = get_logger(__name__)
def load_nucleon(path: pathlib.Path, fmt="toml"):
logger.debug("load_nucleon: 加载文件 %s, 格式: %s", path, fmt)
with open(path, "r") as f:
dictdata = dict()
dictdata = toml.load(f) # type: ignore
logger.debug("TOML 解析成功, keys: %s", list(dictdata.keys()))
lst = list()
nested_data = dict()
# 修正 toml 解析器的不管嵌套行为
for key, value in dictdata.items():
if "__metadata__" in key: # 以免影响句号
if "." in key:
parts = key.split(".")
current = nested_data
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
logger.debug("处理元数据键: %s", key)
else:
nested_data[key] = value
logger.debug("嵌套数据处理完成, keys: %s", list(nested_data.keys()))
# print(nested_data)
for item, attr in nested_data.items():
if item == "__metadata__":
continue
logger.debug("处理项目: %s", item)
lst.append(
(
Nucleon(item, attr, deepcopy(nested_data["__metadata__"])),
deepcopy(nested_data["__metadata__"]["orbital"]),
)
)
logger.debug("load_nucleon 完成, 加载了 %d 个 Nucleon 对象", len(lst))
return lst
def load_electron(path: pathlib.Path, fmt="json") -> dict:
"""从文件路径加载电子对象
Args:
path (pathlib.Path): 路径
fmt (str): 文件格式(可选, 默认 json)
Returns:
dict: 键名是电子对象名称, 值是电子对象
"""
logger.debug("load_electron: 加载文件 %s, 格式: %s", path, fmt)
with open(path, "r") as f:
dictdata = dict()
dictdata = json.load(f) # type: ignore
logger.debug("JSON 解析成功, keys: %s", list(dictdata.keys()))
dic = dict()
for item, attr in dictdata.items():
logger.debug("处理电子项目: %s", item)
dic[item] = Electron(item, attr)
logger.debug("load_electron 完成, 加载了 %d 个 Electron 对象", len(dic))
return dic
+83 -57
View File
@@ -1,78 +1,104 @@
from copy import deepcopy
from heurams.context import config_var
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from heurams.kernel.auxiliary.evalizor import Evalizer
logger = get_logger(__name__) logger = get_logger(__name__)
class Nucleon: class Nucleon:
"""原子核: 带有运行时隔离的模板化只读材料元数据容器""" """原子核: 材料元数据"""
def __init__(self, ident, payload, common): def __init__(self, ident: str, payload: dict, metadata: dict = {}):
"""初始化原子核 (记忆内容)
Args:
ident: 唯一标识符
payload: 记忆内容信息
metadata: 可选元数据信息
"""
logger.debug(
"创建 Nucleon 实例, ident: '%s', payload keys: %s, metadata keys: %s",
ident,
list(payload.keys()) if payload else [],
list(metadata.keys()) if metadata else [],
)
self.metadata = metadata
self.payload = payload
self.ident = ident self.ident = ident
try: logger.debug("Nucleon 初始化完成")
data_safe = deepcopy((payload | common))
data_puz = deepcopy(data_safe["puzzles"])
data_safe["puzzles"] = {}
env = {
"payload": data_safe,
"default": config_var.get()["interface"]["puzzles"],
"nucleon": data_safe,
}
self.evalizer = Evalizer(environment=env)
data_safe = self.evalizer(deepcopy(data_safe))
env = {
"payload": data_safe,
"default": config_var.get()["interface"]["puzzles"],
"nucleon": data_safe,
}
self.evalizer = Evalizer(environment=env)
data_puz = self.evalizer(deepcopy(data_puz))
data_safe["puzzles"] = data_puz # type: ignore
self.data: dict = data_safe # type: ignore
except Exception:
self.data = payload | common
def __getitem__(self, key): def __getitem__(self, key):
if isinstance(key, str): logger.debug("Nucleon.__getitem__: key='%s'", key)
if key == "ident": if key == "ident":
logger.debug("返回 ident: '%s'", self.ident)
return self.ident return self.ident
return self.data[key] if key in self.payload:
value = self.payload[key]
logger.debug(
"返回 payload['%s'], value type: %s", key, type(value).__name__
)
return value
else: else:
raise AttributeError logger.error("'%s' 未在 payload 中找到", key)
raise KeyError(f"Key '{key}' not found in payload.")
def __setitem__(self, key, value):
raise AttributeError("应为只读")
def __delitem__(self, key):
raise AttributeError("应为只读")
def __iter__(self): def __iter__(self):
return iter(self.data) yield from self.payload.keys()
def __contains__(self, key):
return key in (self.data)
def get(self, key, default=None):
if key in self:
return self[key]
return default
def __len__(self): def __len__(self):
return len(self.data) return len(self.payload)
def __repr__(self): def __hash__(self):
from pprint import pformat return hash(self.ident)
s = pformat(self.data, indent=4) def do_eval(self):
return s """
执行并以结果替换当前单元的所有 eval 语句
TODO: 带有限制的 eval, 异步/多线程执行避免堵塞
"""
logger.debug("Nucleon.do_eval 开始")
# eval 环境设置
def eval_with_env(s: str):
try:
nucleon = self
eval_value = eval(s)
if isinstance(eval_value, (int, float)):
ret = str(eval_value)
else:
ret = eval_value
logger.debug(
"eval 执行成功: '%s' -> '%s'",
s,
str(ret)[:50] + "..." if len(ret) > 50 else ret,
)
except Exception as e:
ret = f"此 eval 实例发生错误: {e}"
logger.warning("eval 执行错误: '%s' -> %s", s, e)
return ret
def traverse(data, modifier):
if isinstance(data, dict):
for key, value in data.items():
data[key] = traverse(value, modifier)
return data
elif isinstance(data, list):
for i, item in enumerate(data):
data[i] = traverse(item, modifier)
return data
elif isinstance(data, tuple):
return tuple(traverse(item, modifier) for item in data)
else:
if isinstance(data, str):
if data.startswith("eval:"):
logger.debug("发现 eval 表达式: '%s'", data[5:])
return modifier(data[5:])
return data
traverse(self.payload, eval_with_env)
traverse(self.metadata, eval_with_env)
logger.debug("Nucleon.do_eval 完成")
@staticmethod @staticmethod
def from_data(nucleonic_data: tuple): def placeholder():
_data = nucleonic_data """生成一个占位原子核"""
payload = _data[1][0] logger.debug("创建 Nucleon 占位符")
common = _data[1][1] return Nucleon("核子对象样例内容", {})
ident = _data[0] # TODO:实现eval
return Nucleon(ident, payload, common)
+27 -14
View File
@@ -1,17 +1,30 @@
"""轨道对象""" from typing import TypedDict
# 似乎没有实现这个类的必要... from heurams.services.logger import get_logger
# 那不妨在这儿写点文档
""" logger = get_logger(__name__)
orbital, 即轨道, 是定义队列式复习阶段流程的数据结构, 其实就是个字典, 至于为何不用typeddict, 因为懒. logger.debug("Orbital 类型定义模块已加载")
orbital_example = {
"schedule": [列表 存储阶段(routes)名称] class OrbitalSchedule(TypedDict):
"routes":{ quick_review: list
阶段名称 = [["谜题(puzzle 现称 Puzzles 评估器)名称", "概率系数 可大于1(整数部分为重复次数) 注意使用字符串包裹(toml 规范)"], ...], recognition: list
... final_review: list
}
}
至于谜题定义 放在 nucleon['puzzles'], 这样设计是为了兼容多种不同谜题实现的记忆单元, 尽管如此, 你也可见其谜题调度方式必须是相同的. class Orbital(TypedDict):
schedule: OrbitalSchedule
puzzles: dict
"""一份示例
["__metadata__.orbital.puzzles"] # 谜题定义
"Recognition" = { __origin__ = "recognition", __hint__ = "", primary = "eval:nucleon['content']", secondery = ["eval:nucleon['keyword_note']", "eval:nucleon['note']"], top_dim = ["eval:nucleon['translation']"] }
"SelectMeaning" = { __origin__ = "mcq", __hint__ = "eval:nucleon['content']", jammer = "eval:nucleon['keyword_note']", max_riddles_num = "eval:default['mcq']['max_riddles_num']", prefix = "选择正确项: " }
"FillBlank" = { __origin__ = "cloze", __hint__ = "", text = "eval:nucleon['content']", delimiter = "eval:metadata['formation']['delimiter']", min_denominator = "eval:default['cloze']['min_denominator']"}
["__metadata__.orbital.schedule"] # 内置的推荐学习方案
quick_review = [["FillBlank", "1.0"], ["SelectMeaning", "0.5"], ["recognition", "1.0"]]
recognition = [["recognition", "1.0"]]
final_review = [["FillBlank", "0.7"], ["SelectMeaning", "0.7"], ["recognition", "1.0"]]
""" """
@@ -1,40 +0,0 @@
from .atom import Atom
from .electron import Electron
from .nucleon import Nucleon
orbital_placeholder = {
"schedule": ["quick_review", "recognition", "final_review"],
"routes": {
"quick_review": [
["FillBlank", 1.0],
["SelectMeaning", 0.5],
["Recognition", 1.0],
],
"recognition": [["Recognition", 1.0]],
"final_review": [
["FillBlank", 0.7],
["SelectMeaning", 0.7],
["Recognition", 1.0],
],
},
}
class NucleonPlaceholder(Nucleon):
def __init__(self):
super().__init__("__placeholder__", {}, {})
def __getitem__(self, key):
return f"__placeholder__ attempted {key}"
class ElectronPlaceholder(Electron):
def __init__(self):
super().__init__("__placeholder__", {"": {"": ""}}, "")
class AtomPlaceholder(Atom):
def __init__(self):
super().__init__(
NucleonPlaceholder(), ElectronPlaceholder(), orbital_placeholder
)
+62
View File
@@ -0,0 +1,62 @@
import pathlib
from heurams.context import config_var
from heurams.services.logger import get_logger
logger = get_logger(__name__)
def probe_by_filename(filename):
"""探测指定文件 (无扩展名) 的所有信息"""
logger.debug("probe_by_filename: 探测文件 '%s'", filename)
paths: dict = config_var.get().get("paths")
logger.debug("配置路径: %s", paths)
formats = ["toml", "json"]
result = {}
for item, attr in paths.items():
for i in formats:
attr: pathlib.Path = pathlib.Path(attr) / filename + "." + i
if attr.exists():
logger.debug("找到文件: %s", attr)
result[item.replace("_dir", "")] = str(attr)
else:
logger.debug("文件不存在: %s", attr)
logger.debug("probe_by_filename 结果: %s", result)
return result
def probe_all(is_stem=1):
"""依据目录探测所有信息
Args:
is_stem (boolean): 是否**删除**文件扩展名
Returns:
dict: 有三项, 每一项的键名都是文件组类型, 值都是文件组列表, 只包含文件名
"""
logger.debug("probe_all: 开始探测, is_stem=%d", is_stem)
paths: dict = config_var.get().get("paths")
logger.debug("配置路径: %s", paths)
result = {}
for item, attr in paths.items():
attr: pathlib.Path = pathlib.Path(attr)
result[item.replace("_dir", "")] = list()
logger.debug("扫描目录: %s", attr)
file_count = 0
for i in attr.iterdir():
if not i.is_dir():
file_count += 1
if is_stem:
result[item.replace("_dir", "")].append(str(i.stem))
else:
result[item.replace("_dir", "")].append(str(i.name))
logger.debug("目录 %s 中找到 %d 个文件", attr, file_count)
logger.debug("probe_all 完成, 结果 keys: %s", list(result.keys()))
return result
if __name__ == "__main__":
import os
print(os.getcwd())
print(probe_all())
+39 -2
View File
@@ -1,11 +1,13 @@
""" """
Puzzles 模块 - 生成评估模块 Puzzle 模块 - 谜题生成系统
提供多种类型的辅助评估生成器, 支持从字符串、字典等数据源导入题目 提供多种类型的谜题生成器, 支持从字符串、字典等数据源导入题目
""" """
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
logger = get_logger(__name__)
from .base import BasePuzzle from .base import BasePuzzle
from .cloze import ClozePuzzle from .cloze import ClozePuzzle
from .mcq import MCQPuzzle from .mcq import MCQPuzzle
@@ -24,3 +26,38 @@ puzzles = {
"recognition": RecognitionPuzzle, "recognition": RecognitionPuzzle,
"base": BasePuzzle, "base": BasePuzzle,
} }
@staticmethod
def create_by_dict(config_dict: dict) -> BasePuzzle:
"""
根据配置字典创建谜题
Args:
config_dict: 配置字典, 包含谜题类型和参数
Returns:
BasePuzzle: 谜题实例
Raises:
ValueError: 当配置无效时抛出
"""
logger.debug(
"puzzles.create_by_dict: config_dict keys=%s", list(config_dict.keys())
)
puzzle_type = config_dict.get("type")
if puzzle_type == "cloze":
return puzzles["cloze"](
text=config_dict["text"],
min_denominator=config_dict.get("min_denominator", 7),
)
elif puzzle_type == "mcq":
return puzzles["mcq"](
mapping=config_dict["mapping"],
jammer=config_dict.get("jammer", []),
max_riddles_num=config_dict.get("max_riddles_num", 2),
prefix=config_dict.get("prefix", ""),
)
else:
raise ValueError(f"未知的谜题类型: {puzzle_type}")
-10
View File
@@ -1,10 +0,0 @@
from heurams.services.logger import get_logger
from .base import BasePuzzle
logger = get_logger(__name__)
class GuessPuzzle(BasePuzzle):
def __init__(self):
super().__init__()
+1 -1
View File
@@ -72,7 +72,7 @@ class MCQPuzzle(BasePuzzle):
# 确保至少有4个干扰项 # 确保至少有4个干扰项
while len(self.jammer) < 4: while len(self.jammer) < 4:
self.jammer.append("") self.jammer.append(" " * (4 - len(self.jammer)))
unique_jammers = set(jammer + list(self.mapping.values())) unique_jammers = set(jammer + list(self.mapping.values()))
@@ -1,4 +1,5 @@
# mcq.py # mcq.py
import random
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
@@ -16,3 +17,4 @@ class RecognitionPuzzle(BasePuzzle):
def refresh(self): def refresh(self):
logger.debug("RecognitionPuzzle.refresh(空实现)") logger.debug("RecognitionPuzzle.refresh(空实现)")
pass
-173
View File
@@ -1,173 +0,0 @@
# Reactor - 记忆流程状态机模块
Reactor 是 HeurAMS 的记忆流程状态机模块, 和界面 (interface) 的实现是解耦的, 以便后期与其他框架的适配.\
得益于 Pickle, 状态机模块支持快照!
## Router - 全局阶段控制器
在一次队列记忆流程中, Router 代表记忆流程本身.
### 属性
#### 状态属性
其有状态属性:
- unsure - 用于初始化
- \*quick_review - 复习逾期的单元
- \*recognition - 辨识新单元
- \*final_review - 复习所有逾期的和新辨认的单元
- finished - 表示完成
> 逾期的: 指 SM-2 算法间隔显示应该复习的单元
带 * 的属性表示实际的记忆阶段, 由 repo 中 schedule.toml 中 schedule 列表显式声明, 运行过程中可以选择性执行, "空的" Procession 会被直接跳过.
在初始化 Procession 时, 每个 Procession 被赋予一个不重复的状态属性 作为"阶段状态"属性, 以此标识 Procession 的阶段属性, 因为每个 Procession 管理一个阶段下的复习进程.
你可以用 state 属性获取 Router 的当前状态.
#### Procession 属性
储存一个顺序列表, 保存所有构造的 Procession.\
顺序与 repo 中 schedule.toml 中 schedule 列表中的顺序完全相同
### 初始化
Router 接受一个存储 Atom 对象的列表, 作为组织记忆流程的材料\
在内部, 根据是否激活将其分为 new_atoms 与 old_atoms.\
因此, 如果你传入的列表中有算法上"无所事事"的 Atom, 流程会对其进行"加强复习"
由此创建 Procession.
### 直接输出呈现形式
Router 的 __repr__ 定义了此对象"官方的显示"用作直观的调试.\
其以 ascii 表格形式输出, 格式也符合 markdown 表格规范, 你可以直接复制到 markdown.\
示例:
```text
| Type | State | Processions | Current Procession |
| :----- | :----- | :--------------------- | :----------------- |
| Router | unsure | ['新记忆', '总体复习'] | 新记忆 |
```
| Type | State | Processions | Current Procession |
| :----- | :----- | :--------------------- | :----------------- |
| Router | unsure | ['新记忆', '总体复习'] | 新记忆 |
### 方法
作为一个 Transition Machine 对象的继承, 其拥有 Machine 对象拥有的所有方法.\
除此之外, 它也拥有一些其他方法.
#### current_procession(self)
用于查询当前的 Procession, 并且根据当前 Procession 更新自身状态.\
返回一个 Procession 对象, 是当前阶段的 Procession.\
内部运作是返回第一个状态不为 finished 的 Procession, 并将自身状态变更为 Procession 的"阶段状态"属性\
若所有 Procession 都已完成, 将返回一个"阶段状态"为 finished 的 Procession 占位符对象(它不在 procession 属性中), 并更新自身状态为 finished.
## Procession - 阶段管理器
### 属性
#### 状态属性
其有状态属性:
- active - 标识未完成, 初始化的默认属性
- finished - 完成了
#### 其他属性
- current_atom: 当前记忆原子的引用
- atoms: 队列中所有原子列表
- cursor: 指针, 是当前原子在 atoms 列表中的索引
- route: "阶段属性"
> 注意区分 "Router" 和 "Route", 其中 "Route" 表示 "Router State".
- name\_: 阶段的命名
- state: 当前状态属性
### 初始化
接受一个 atoms 列表与 route_state (RouterState Enum 类型)对象
### 直接输出呈现形式
同 Router, 但显示数据有所不同\
与 Router 不同, Procession 显示队列会对过长的 atom.ident 进行缩略(末尾 `>` 符号)
```text
| Type | Name | State | Progress | Procession | Current Atom |
| :--------- | :----- | :----- | :------- | :--------------------- | :---------------------------- |
| Procession | 新记忆 | active | 1 / 2 | ['秦孝公>', '君臣固>'] | 秦孝公据崤函之固, 拥雍州之地, |
```
| Type | Name | State | Progress | Procession | Current Atom |
| :--------- | :----- | :----- | :------- | :--------------------- | :---------------------------- |
| Procession | 新记忆 | active | 1 / 2 | ['秦孝公>', '君臣固>'] | 秦孝公据崤函之固, 拥雍州之地, |
### 方法
作为一个 Transition Machine 对象的继承, 其拥有 Machine 对象拥有的所有方法.\
除此之外, 它也拥有一些其他方法.
#### forward(self, step=1)
移动 cursor 并依情况更新 current_atom 和状态属性\
无论 Procession 是否处于完成状态, forward 操作都是可逆的, 你可以传入负数, 此时已完成的 Procession 会自动"重启".
#### append(self, atom=None)
追加(回忆失败的)原子(默认为当前原子, 传入 None 会自动转化为当前原子)到队列末端\
如果这个原子已经处于队列末端, 不会重复追加, 除非队列只剩下这个原子还没完成(此时最多重复两个)
#### process(self)
返回 cursor 值
#### __len__(self)
返回剩余原子量(而不是原子总量)\
可以使用 len 函数调用
获取原子总量请用 len(obj.atoms), 或者 total_length(self) 方法
#### total_length(self)
返回队列原子总量
#### is_empty(self)
判断是否为空队列(传入原子列表对象是空列表的队列)
#### get_expander(self)
获取当前原子的 Expander 对象, 用于单原子调度展开
## Expander - 单原子调度控制器
### 属性
#### 状态属性
- exammode: 测试模式(默认)
- retronly: 仅回顾模式
#### 其他属性
- cursor
- atom
- current_puzzle
- orbital_schedule
- orbital_puzzles
- puzzles
### 初始化
接受 atom 对象和 route 参数
### 方法
#### get_puzzles(self)
+8 -4
View File
@@ -1,8 +1,12 @@
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .expander import Expander from .fission import Fission
from .router import Router from .phaser import Phaser
from .procession import Procession from .procession import Procession
from .states import RouterState, ProcessionState from .states import PhaserState, ProcessionState
__all__ = ["RouterState", "ProcessionState", "Procession", "Expander", "Router"] logger = get_logger(__name__)
__all__ = ["PhaserState", "ProcessionState", "Procession", "Fission", "Phaser"]
logger.debug("反应堆模块已加载")
-131
View File
@@ -1,131 +0,0 @@
import random
from functools import reduce
from tabulate import tabulate as tabu
from transitions import Machine
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as puz
from heurams.services.logger import get_logger
from .states import ExpanderState, RouterState
logger = get_logger(__name__)
class Expander(Machine):
"""单原子调度展开器"""
def __init__(self, atom: pt.Atom, route=RouterState.RECOGNITION):
self.route = route
self.cursor = 0
self.atom = atom
self.current_puzzle_inf: dict
# route 为 RouterState 枚举实例, 需要获取其value
route_value = route.value
states = [
{"name": ExpanderState.EXAMMODE.value},
{"name": ExpanderState.RETRONLY.value},
]
transitions = [
{
"trigger": "finish",
"source": ExpanderState.EXAMMODE.value,
"dest": ExpanderState.RETRONLY.value,
},
]
if route == RouterState.FINISHED:
Machine.__init__(
self,
states=states,
transitions=transitions,
initial=ExpanderState.EXAMMODE.value,
)
return
orbital_schedule = atom.registry["orbital"]["routes"][route_value] # type: ignore
orbital_puzzles = atom.registry["nucleon"]["puzzles"]
self.puzzles_inf = list()
self.min_ratings = []
for item, possibility in orbital_schedule: # type: ignore
logger.debug(f"开始处理: {item}")
puzzle = puz.puzzles[orbital_puzzles[item]["__origin__"]]
if not isinstance(possibility, float):
possibility = float(possibility)
while possibility > 1:
self.puzzles_inf.append(
{
"puzzle": puzzle,
"alia": item,
}
)
possibility -= 1
if random.random() <= possibility:
self.puzzles_inf.append(
{
"puzzle": puzzle,
"alia": item,
}
)
if self.puzzles_inf:
self.current_puzzle_inf = self.puzzles_inf[0]
for i in range(len(self.puzzles_inf)):
self.min_ratings.append(float("inf"))
Machine.__init__(
self,
states=states,
transitions=transitions,
initial=ExpanderState.EXAMMODE.value,
)
def get_puzzles_inf(self):
if self.state == "retronly":
return [{"puzzle": puz.puzzles["recognition"], "alia": "Recognition"}]
return self.puzzles_inf
def get_current_puzzle_inf(self):
if self.state == "retronly":
return {"puzzle": puz.puzzles["recognition"], "alia": "Recognition"}
return self.current_puzzle_inf
def report(self, rating):
if self.puzzles_inf:
self.min_ratings[self.cursor] = min(rating, self.min_ratings[self.cursor])
def get_quality(self):
if self.puzzles_inf:
if self.is_state("retronly", self):
return reduce(lambda x, y: min(x, y), self.min_ratings)
raise IndexError
def forward(self, step=1):
"""将谜题指针向前移动并依情况更新或完成"""
self.cursor += step
if self.cursor >= len(self.puzzles_inf):
if self.state != "retronly":
self.finish()
else:
self.current_puzzle_inf = self.puzzles_inf[self.cursor]
def __repr__(self, style="pipe", ends="\n") -> str:
from heurams.services.textproc import truncate
dic = [
{
"Type": "Expander",
"Atom": truncate(self.atom.ident),
"State": self.state,
"Progress": f"{self.cursor + 1} / {len(self.puzzles_inf)}",
"Procession": list(
map(lambda f: truncate(f["alia"]), self.puzzles_inf)
),
"Current Puzzle": f"{self.current_puzzle_inf['alia']}@{self.current_puzzle_inf['puzzle'].__name__}", # type: ignore
}
]
return str(tabu(dic, headers="keys", tablefmt=style)) + ends
+45
View File
@@ -0,0 +1,45 @@
import random
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as puz
from heurams.services.logger import get_logger
from .states import PhaserState
class Fission:
"""裂变器: 单原子调度展开器"""
def __init__(self, atom: pt.Atom, phase=PhaserState.RECOGNITION):
self.logger = get_logger(__name__)
self.atom = atom
# print(f"{phase.value}")
self.orbital_schedule = atom.registry["orbital"]["schedule"][phase.value] # type: ignore
self.orbital_puzzles = atom.registry["orbital"]["puzzles"]
# print(self.orbital_schedule)
self.puzzles = list()
for item, possibility in self.orbital_schedule: # type: ignore
print(f"ad:{item}")
self.logger.debug(f"开始处理 orbital 项: {item}")
if not isinstance(possibility, float):
possibility = float(possibility)
while possibility > 1:
self.puzzles.append(
{
"puzzle": puz.puzzles[self.orbital_puzzles[item]["__origin__"]],
"alia": item,
}
)
possibility -= 1
if random.random() <= possibility:
self.puzzles.append(
{
"puzzle": puz.puzzles[self.orbital_puzzles[item]["__origin__"]],
"alia": item,
}
)
print(f"ok:{item}")
self.logger.debug(f"orbital 项处理完成: {item}")
def generate(self):
yield from self.puzzles
+51
View File
@@ -0,0 +1,51 @@
# 移相器类定义
import heurams.kernel.particles as pt
from heurams.services.logger import get_logger
from .procession import Procession
from .states import PhaserState, ProcessionState
logger = get_logger(__name__)
class Phaser:
"""移相器: 全局调度阶段管理器"""
def __init__(self, atoms: list[pt.Atom]) -> None:
logger.debug("Phaser.__init__: 原子数量=%d", len(atoms))
new_atoms = list()
old_atoms = list()
self.state = PhaserState.UNSURE
for i in atoms:
if not i.registry["electron"].is_activated():
new_atoms.append(i)
else:
old_atoms.append(i)
logger.debug("新原子数量=%d, 旧原子数量=%d", len(new_atoms), len(old_atoms))
self.processions = list()
if len(old_atoms):
self.processions.append(
Procession(old_atoms, PhaserState.QUICK_REVIEW, "初始复习")
)
logger.debug("创建初始复习 Procession")
if len(new_atoms):
self.processions.append(
Procession(new_atoms, PhaserState.RECOGNITION, "新记忆")
)
logger.debug("创建新记忆 Procession")
self.processions.append(Procession(atoms, PhaserState.FINAL_REVIEW, "总体复习"))
logger.debug("创建总体复习 Procession")
logger.debug("Phaser 初始化完成, processions 数量=%d", len(self.processions))
def current_procession(self):
logger.debug("Phaser.current_procession 被调用")
for i in self.processions:
i: Procession
if not i.state == ProcessionState.FINISHED:
self.state = i.phase
logger.debug("找到未完成的 Procession: phase=%s", i.phase)
return i
self.state = PhaserState.FINISHED
logger.debug("所有 Procession 已完成, 状态设置为 FINISHED")
return 0
+32 -90
View File
@@ -1,101 +1,61 @@
from tabulate import tabulate as tabu
from transitions import Machine
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .expander import Expander from .states import PhaserState, ProcessionState
from .states import RouterState, ProcessionState
logger = get_logger(__name__) logger = get_logger(__name__)
class Procession(Machine): class Procession:
"""队列: 标识单次记忆流程""" """队列: 标识单次记忆流程"""
def __init__(self, atoms: list, route_state: RouterState, name_: str = ""): def __init__(self, atoms: list, phase: PhaserState, name: str = ""):
logger.debug( logger.debug(
"Procession.__init__: 原子数量=%d, route=%s, name='%s'", "Procession.__init__: 原子数量=%d, phase=%s, name='%s'",
len(atoms), len(atoms),
route_state.value, phase.value,
name_, name,
) )
self.current_atom: pt.Atom | None
self.atoms = atoms self.atoms = atoms
self.current_atom = atoms[0] if atoms else None self.queue = atoms.copy()
self.current_atom = atoms[0]
self.cursor = 0 self.cursor = 0
self.name_ = name_ self.name = name
self.route = route_state self.phase = phase
self.state: ProcessionState = ProcessionState.RUNNING
states = [ logger.debug("Procession 初始化完成, 队列长度=%d", len(self.queue))
{"name": ProcessionState.ACTIVE.value, "on_enter": "on_active"},
{"name": ProcessionState.FINISHED.value, "on_enter": "on_finished"},
]
transitions = [
{
"trigger": "finish",
"source": ProcessionState.ACTIVE.value,
"dest": ProcessionState.FINISHED.value,
},
{
"trigger": "restart",
"source": ProcessionState.FINISHED.value,
"dest": ProcessionState.ACTIVE.value,
},
]
Machine.__init__(
self,
states=states,
transitions=transitions,
initial=ProcessionState.ACTIVE.value,
)
logger.debug("Procession 初始化完成, 队列长度=%d", len(self.atoms))
def on_active(self):
"""进入active状态时的回调"""
logger.debug("Procession 进入 active 状态")
def on_finished(self):
"""进入FINISHED状态时的回调"""
logger.debug("Procession 进入 FINISHED 状态")
def forward(self, step=1): def forward(self, step=1):
"""将记忆原子指针向前移动并依情况更新原子(返回 1)或完成队列(返回 0)"""
logger.debug("Procession.forward: step=%d, 当前 cursor=%d", step, self.cursor) logger.debug("Procession.forward: step=%d, 当前 cursor=%d", step, self.cursor)
self.cursor += step self.cursor += step
if self.cursor >= len(self.atoms): if self.cursor == len(self.queue):
if self.state != ProcessionState.FINISHED.value: self.state = ProcessionState.FINISHED
self.finish() # 触发状态转换
logger.debug("Procession 已完成") logger.debug("Procession 已完成")
else: else:
if self.state != ProcessionState.ACTIVE.value: self.state = ProcessionState.RUNNING
self.restart() # 确保在active状态 try:
self.current_atom = self.atoms[self.cursor]
logger.debug("cursor 更新为: %d", self.cursor) logger.debug("cursor 更新为: %d", self.cursor)
logger.debug( self.current_atom = self.queue[self.cursor]
"当前原子更新为: %s", logger.debug("当前原子更新为: %s", self.current_atom.ident)
self.current_atom.ident if self.current_atom else "None", return 1 # 成功
) except IndexError as e:
logger.debug("IndexError: %s", e)
self.state = ProcessionState.FINISHED
logger.debug("Procession 因索引错误而完成")
return 0
def append(self, atom=None): def append(self, atom=None):
"""追加(回忆失败的)原子(默认为当前原子)到队列末端""" if atom == None:
if atom is None:
atom = self.current_atom atom = self.current_atom
logger.debug("Procession.append: atom=%s", atom.ident if atom else "None") logger.debug("Procession.append: atom=%s", atom.ident if atom else "None")
if self.queue[len(self.queue) - 1] != atom or len(self) <= 1:
if not self.atoms or self.atoms[-1] != atom or len(self) <= 1: self.queue.append(atom)
self.atoms.append(atom) logger.debug("原子已追加到队列, 新队列长度=%d", len(self.queue))
logger.debug("原子已追加到队列, 新队列长度=%d", len(self.atoms))
else: else:
logger.debug("原子未追加(重复或队列长度<=1)") logger.debug("原子未追加(重复或队列长度<=1)")
def __len__(self): def __len__(self):
if not self.atoms: length = len(self.queue) - self.cursor
return 0
length = len(self.atoms) - self.cursor
logger.debug("Procession.__len__: 剩余长度=%d", length) logger.debug("Procession.__len__: 剩余长度=%d", length)
return length return length
@@ -104,29 +64,11 @@ class Procession(Machine):
return self.cursor return self.cursor
def total_length(self): def total_length(self):
total = len(self.atoms) total = len(self.queue)
logger.debug("Procession.total_length: %d", total) logger.debug("Procession.total_length: %d", total)
return total return total
def is_empty(self): def is_empty(self):
empty = len(self.atoms) == 0 empty = len(self.queue)
logger.debug("Procession.is_empty: %s", empty) logger.debug("Procession.is_empty: %d", empty)
return empty return empty
def get_expander(self):
return Expander(atom=self.current_atom, route=self.route) # type: ignore
def __repr__(self, style="pipe", ends="\n"):
from heurams.services.textproc import truncate
dic = [
{
"Type": "Procession",
"Name": self.name_,
"State": self.state,
"Progress": f"{self.cursor + 1} / {len(self.atoms)}",
"Procession": list(map(lambda f: truncate(f.ident), self.atoms)),
"Current Atom": self.current_atom.ident, # type: ignore
}
]
return str(tabu(dic, headers="keys", tablefmt=style)) + ends
-147
View File
@@ -1,147 +0,0 @@
from transitions import Machine
import heurams.kernel.particles as pt
from heurams.kernel.particles.placeholders import AtomPlaceholder
from heurams.services.logger import get_logger
from .procession import Procession
from .states import RouterState, ProcessionState
logger = get_logger(__name__)
class Router(Machine):
"""全局调度阶段路由器"""
def __init__(self, atoms: list[pt.Atom]) -> None:
logger.debug(f"Router.__init__: 原子数量={len(atoms)}")
self.atoms = atoms
new_atoms = list()
old_atoms = list()
for i in atoms:
if not i.registry["electron"].is_activated():
new_atoms.append(i)
else:
old_atoms.append(i)
logger.debug(f"新原子数量={len(new_atoms)}, 旧原子数量={len(old_atoms)}")
self.processions = list()
"""路由中的所有队列"""
# TODO: 改进为基于配置文件的可选复习阶段
if len(old_atoms):
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))
# 设置transitions状态机
states = [
{"name": RouterState.UNSURE.value, "on_enter": "on_unsure"},
{"name": RouterState.QUICK_REVIEW.value, "on_enter": "on_quick_review"},
{"name": RouterState.RECOGNITION.value, "on_enter": "on_recognition"},
{"name": RouterState.FINAL_REVIEW.value, "on_enter": "on_final_review"},
{"name": RouterState.FINISHED.value, "on_enter": "on_finished"},
]
transitions = [
{"trigger": "to_unsure", "source": "*", "dest": RouterState.UNSURE.value},
{
"trigger": "to_quick_review",
"source": "*",
"dest": RouterState.QUICK_REVIEW.value,
},
{
"trigger": "to_recognition",
"source": "*",
"dest": RouterState.RECOGNITION.value,
},
{
"trigger": "to_final_review",
"source": "*",
"dest": RouterState.FINAL_REVIEW.value,
},
{
"trigger": "to_finished",
"source": "*",
"dest": RouterState.FINISHED.value,
},
]
Machine.__init__(
self,
states=states,
transitions=transitions,
initial=RouterState.UNSURE.value,
)
self.to_unsure()
def on_unsure(self):
"""进入UNSURE状态时的回调"""
logger.debug("Router 进入 UNSURE 状态")
def on_quick_review(self):
"""进入QUICK_REVIEW状态时的回调"""
logger.debug("Router 进入 QUICK_REVIEW 状态")
def on_recognition(self):
"""进入RECOGNITION状态时的回调"""
logger.debug("Router 进入 RECOGNITION 状态")
def on_final_review(self):
"""进入FINAL_REVIEW状态时的回调"""
logger.debug("Router 进入 FINAL_REVIEW 状态")
def on_finished(self):
"""进入FINISHED状态时的回调"""
for i in self.atoms:
i.lock(1)
i.revise()
logger.debug("Router 进入 FINISHED 状态")
def current_procession(self):
logger.debug("Router.current_procession 被调用")
for i in self.processions:
i: Procession
if i.state != ProcessionState.FINISHED.value:
# if i.route == RouterState.UNSURE: 此判断是不必要的 因为没有这种 Procession
if i.route == RouterState.QUICK_REVIEW:
self.to_quick_review()
elif i.route == RouterState.RECOGNITION:
self.to_recognition()
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"):
from tabulate import tabulate as tabu
lst = [
{
"Type": "Router",
"State": self.state,
"Processions": list(map(lambda f: (f.name_), self.processions)),
"Current Procession": "None" if not self.current_procession() else self.current_procession().name_, # type: ignore
},
]
return str(tabu(tabular_data=lst, headers="keys", tablefmt=style)) + ends
+4 -9
View File
@@ -1,11 +1,11 @@
from enum import Enum from enum import Enum, auto
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
class RouterState(Enum): class PhaserState(Enum):
UNSURE = "unsure" UNSURE = "unsure"
QUICK_REVIEW = "quick_review" QUICK_REVIEW = "quick_review"
RECOGNITION = "recognition" RECOGNITION = "recognition"
@@ -14,13 +14,8 @@ class RouterState(Enum):
class ProcessionState(Enum): class ProcessionState(Enum):
ACTIVE = "active" RUNNING = auto()
FINISHED = "finished" FINISHED = auto()
class ExpanderState(Enum):
EXAMMODE = "exammode"
RETRONLY = "retronly"
logger.debug("状态枚举定义已加载") logger.debug("状态枚举定义已加载")
-3
View File
@@ -1,3 +0,0 @@
from .repo import Repo, RepoManifest
__all__ = ["Repo", "RepoManifest"]

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