15 Commits

89 changed files with 4424 additions and 2307 deletions

12
.gitignore vendored
View File

@@ -5,22 +5,18 @@
__pycache__/ __pycache__/
.idea/ .idea/
cache/ cache/
#nucleon/test.toml data/repo/cngk
electron/test.toml data/repo/eotgk
data/repo/evtgk
*.egg-info/ *.egg-info/
build/ build/
dist/ dist/
old/ old/
# config/
data/cache/ data/cache/
data/electron/
data/nucleon/
data/global/ data/global/
!data/nucleon/TEST*
data/orbital/
config/config_dev.toml config/config_dev.toml
AGENTS.md AGENTS.md
*.log.1 *.log.*
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

View File

@@ -3,22 +3,23 @@
欢迎为此项目做出贡献! 欢迎为此项目做出贡献!
本项目是一个开源项目, 我们鼓励社区成员参与改进. 本项目是一个开源项目, 我们鼓励社区成员参与改进.
## 开发流程 ## 开发规范
1. **分支划分**: 1. 分支划分:
- `main` 分支: 稳定版本 - `main` 分支: 稳定版本
- `dev` 分支: 开发版本 - `dev` 分支: 开发版本
- 功能分支: 从 `dev` 分支创建, 命名格式为 `feature/描述``fix/描述``refactor/描述` - 功能分支: 从 `dev` 分支创建, 命名格式为 `feature/描述``fix/描述``refactor/描述`
2. **代码风格**: 2. 代码风格:
- 请使用 Black 与 isort 格式化代码 - 请使用 Black 格式化代码
- 遵循 PEP 8 规范 - 遵循 PEP 8 规范
- 添加适当的文档字符串 - 添加适当的文档字符串
3. **提交消息**: 3. 提交消息:
- 使用简体中文或英文撰写清晰的提交消息 - 使用简体中文或英文撰写清晰的提交消息
- 格式: 遵循 Conventional Commits 规范 - 格式: 遵循 Conventional Commits 规范
4. **合并方式**: 4. 合并方式:
- 不使用 Fast-forward 合并 - 不使用 Fast-forward 合并
- 可以设置 `git config merge.ff false` - 可以设置 `git config merge.ff false`
## 设置开发环境 ## 设置开发环境
```bash ```bash

110
README.md
View File

@@ -9,30 +9,72 @@
本仓库在 AGPLv3 下开放源代码(详见 LICENSE 文件) 本仓库在 AGPLv3 下开放源代码(详见 LICENSE 文件)
## 版本日志 ## 版本日志
- 0.0.x: 简易调度器实现与最小原型.
- 0.1.x: 命令行操作的调度器. ### 0.0.x
- 0.2.x: 使用 Textual 构建富文本终端用户界面; 项目可行性验证; 采用 SM-2 原始算法, 评估方式为用户自评估原型. - 简易调度器实现与最小原型
- 0.3.x: 简单的多文件项目; 创建了记忆内容/算法数据结构; 基于 SM-2 改进算法的自动复习测评评估; 重点设计古诗文记忆理解功能; TUI 界面改进; 简单的 TTS 集成.
- 0.4.x: 开发目标转为多用途; 使用模块管理解耦设计; 增加文档与类型标注; 采用上下文设计模式的隐式依赖注入与遵从 IoC, 注册器设计的算法与功能实现; 支持其他调度算法模块 (SM-2, SM-18M 逆向工程实现, FSRS) 与谜题模块; 采用日志调试; 更新文件格式; 引入动态数据模式(宏驱动的动态内容生成), 与基于文件的策略调控; 更佳的用户数据处理; 加入模块化扩展集成; 将算法数据格式换为 json 提高性能; 采用 provider-service 抽象架构, 支持切换服务提供者; 整体兼容性改进. ### 0.1.x
- 0.5.x: 以仓库(repo)对象作为文件系统与运行时对象间的桥梁, 提高解耦性与性能; 使用具有列表 - 字典 API 同步特性的 "Lict" 对象作为 Repo 数据的内部存储; 将粒子对象作为纯运行时对象, 数据通过引用自动同步至 Repo, 减少负担; 实现声音形式回顾 "电台" 功能; 改进数据存储结构, 实现选择性持久化; 增强可配置性; 使用 Transitions 状态机库重新实现 reactor 状态机, 增强可维护性; 实现整体回顾记忆功能, 与队列式记忆功能并列; 加入状态机快照功能(基于 pickle), 使中断的记忆流程得以恢复; 增加"整体文章引用"功能, 实现从一篇长文本中摘取内容片段记忆并在原文中高亮查看的组织操作. - 命令行操作的调度器
> 下一步?
> 使用 Flutter 构建酷酷的现代化前端, 增加云同步/文档源服务... ### 0.2.x
- 使用 Textual 构建富文本终端用户界面
- 项目可行性验证
- 采用 SM-2 原始算法, 评估方式为用户自评估原型
### 0.3.x Frontal 前端
- 简单的多文件项目
- 创建了记忆内容/算法数据结构
- 基于 SM-2 改进算法的自动复习测评评估
- 重点设计古诗文记忆理解功能
- TUI 界面改进
- 简单的 TTS 集成
### 0.4.x Fledge 雏鸟
- 开发目标转为多用途
- 使用模块管理解耦设计
- 增加文档与类型标注
- 采用上下文设计模式的隐式依赖注入与遵从 IoC, 注册器设计的算法与功能实现
- 支持其他调度算法模块 (SM-2, SM-18M 参考理论变体, FSRS) 与谜题模块
- 采用规范的日志调试取代 Textual Devtools 调试
- 更新数据持久化协议规范
- 引入动态数据模式 (宏驱动的动态内容生成) , 与基于文件的策略调控
- 更佳的用户数据处理
- 加入模块化扩展集成
- 更换算法数据格式, 提高性能
- 采用 provider-service 抽象架构, 支持切换服务提供者
- 整体兼容性改进
### 0.5.x Fulcrum 支点
- 以仓库 (repository) 对象作为文件系统与运行时对象间的桥梁, 提高解耦性与性能
- 使用具有列表-字典 API 同步特性的 "Lict" 对象作为 Repo 数据的内部存储
- 将粒子对象作为纯运行时对象, 数据通过引用自动同步至 Repo, 减少负担
- 实现声音形式回顾 "电台" 功能
- 改进数据存储结构, 实现选择性持久化
- 增强可配置性
- 使用 Transitions 状态机库重新实现 Reactor 模块系列状态机, 增强可维护性
- 实现整体回顾记忆功能, 与队列式记忆功能并列
- 加入状态机快照功能 (基于 pickle) , 使中断的记忆流程得以恢复
- 增加 "整体文章引用" 功能, 实现从一篇长文本中摘取内容片段记忆并在原文中高亮查看的组织操作
### 下一步?
- 增加云同步 / 文档源服务
- 使用 Flutter 构建酷酷的现代化前端
- ...
## 特性 ## 特性
### 间隔迭代算法 ### 间隔迭代算法
> 许多出版物都广泛讨论了不同重复间隔对学习效果的影响. 特别是, 间隔效应被认为是一种普遍现象. 间隔效应是指, 如果重复的间隔是分散/稀疏的, 而不是集中重复, 那么学习任务的表现会更好. 因此, 有观点提出, 学习中使用的最佳重复间隔是**最长的但不会导致遗忘的间隔**. > 许多出版物都广泛讨论了不同重复间隔对学习效果的影响. 特别是, 间隔效应被认为是一种普遍现象. 间隔效应是指, 如果重复的间隔是分散/稀疏的, 而不是集中重复, 那么学习任务的表现会更好. 因此, 有观点提出, 学习中使用的最佳重复间隔是**最长的, 但不会导致遗忘的间隔**.
- 采用经实证的 SM-2 间隔迭代算法, 此算法亦用作 Anki 闪卡记忆软件的默认闪卡调度器 - 采用经实证的 SM-2 间隔迭代算法, 此算法亦用作 Anki 闪卡记忆软件的默认闪卡调度器
- 动态规划每个记忆单元的记忆间隔时间表 - 动态规划每个记忆单元的记忆间隔时间表
- 动态跟踪记忆反馈数据, 优化长期记忆保留率与稳定性 - 动态跟踪记忆反馈数据, 优化长期记忆保留率与稳定性
### 学习进程优化 ### 学习进程优化
- 逐字解析: 支持逐字详细释义解析 - 元数据配置: 支持配置详细附加数据
- 语法分析: 接入生成式人工智能, 支持古文结构交互式解析 - 自然语音: 集成文本转语音 (TTS) 功能, 支持"电台"回顾功能
- 自然语音: 集成微软神经网络文本转语音 (TTS) 技术 - 多种谜题类型: 选择题 (MCQ), 填空题 (Cloze), 识别题 (Recognition)
- 多种谜题类型: 选择题 (MCQ)、填空题 (Cloze)、识别题 (Recognition)
- 动态内容生成: 支持宏驱动的模板系统, 根据上下文动态生成题目 - 动态内容生成: 支持宏驱动的模板系统, 根据上下文动态生成题目
- 云同步支持: 通过 WebDAV 协议同步数据到远程服务器 - 云同步支持: 通过多种协议同步数据到远程服务器
### 实用用户界面 ### 实用用户界面
- 响应式 Textual 框架构建的跨平台 TUI 界面 - 响应式 Textual 框架构建的跨平台 TUI 界面
@@ -40,10 +82,10 @@
- 简洁直观的复习流程设计 - 简洁直观的复习流程设计
### 架构特性 ### 架构特性
- 模块化设计: 算法、谜题、服务提供者可插拔替换 - 模块化设计: 算法, 谜题, 服务提供者可插拔替换
- 上下文管理: 使用 ContextVar 实现隐式依赖注入 - 上下文管理: 使用 ContextVar 实现隐式依赖注入
- 数据持久化: TOML 配置与内容, JSON 算法状态 - 数据持久化: TOML 配置与内容, JSON 算法状态
- 服务抽象: 音频播放TTSLLM 通过 provider 架构支持多种后端 - 服务抽象: 音频播放, TTS, LLM 通过 provider 架构支持多种后端
- 完整日志系统: 带轮转的日志记录, 便于调试 - 完整日志系统: 带轮转的日志记录, 便于调试
## 安装 ## 安装
@@ -65,43 +107,13 @@
pip install -e . pip install -e .
``` ```
## 使 ## 启动应
### 启动应用
```bash ```bash
# 在任一目录(建议是空目录或者包根目录, 将被用作存放数据)下运行 # 在任一目录(建议是空目录或者包根目录, 将被用作存放数据)下运行
python -m heurams.interface python -m heurams.interface
``` ```
### 数据目录结构 配置文件位于 `./data/config/config.toml`(相对于工作目录). 如果不存在, 会使用内置的默认配置.
应用会在工作目录下创建以下数据目录:
- `data/nucleon/`: 记忆内容 (TOML 格式)
- `data/electron/`: 算法状态 (JSON 格式)
- `data/orbital/`: 策略配置 (TOML 格式)
- `data/cache/`: 音频缓存文件
- `data/template/`: 内容模板
首次运行时会自动创建这些目录.
## 配置
配置文件位于 `config/config.toml`(相对于工作目录). 如果不存在, 会使用内置的默认配置.
### 同步配置
同步功能支持 WebDAV 协议,可在配置文件的 `[sync.webdav]` 段进行配置:
```toml
[sync.webdav]
enabled = false
url = "" # WebDAV 服务器地址
username = "" # 用户名
password = "" # 密码
remote_path = "/heurams/" # 远程路径
sync_mode = "bidirectional" # 同步模式: bidirectional/upload_only/download_only
conflict_strategy = "newer" # 冲突策略: newer/ask/keep_both
verify_ssl = true # SSL 证书验证
```
启用同步后,可通过应用内的同步工具进行数据备份和恢复。
## 项目结构 ## 项目结构
@@ -170,7 +182,7 @@ graph TB
``` ```
src/heurams/ src/heurams/
├── __init__.py # 包入口点 ├── __init__.py # 包入口点
├── context.py # 全局上下文、路径、配置上下文管理器 ├── context.py # 全局上下文, 路径, 配置上下文管理器
├── services/ # 核心服务 ├── services/ # 核心服务
│ ├── config.py # 配置管理 │ ├── config.py # 配置管理
│ ├── logger.py # 日志系统 │ ├── logger.py # 日志系统

View File

@@ -6,7 +6,10 @@ daystamp_override = -1
timestamp_override = -1 timestamp_override = -1
# [调试] 一键通过 # [调试] 一键通过
quick_pass = 1 quick_pass = true
# [调试] 自动化测试模式(仅用于测试完整性)
auto_pass = false
# 对于每个项目的默认新记忆原子数量 # 对于每个项目的默认新记忆原子数量
scheduled_num = 8 scheduled_num = 8
@@ -17,7 +20,7 @@ timezone_offset = +28800 # 中国标准时间 (UTC+8)
[interface] [interface]
[interface.memorizor] [interface.memorizor]
autovoice = true # 自动语音播放, 仅限于 recognition 组件 autovoice = false # 自动语音播放, 仅限于 recognition 组件
[algorithm] [algorithm]
default = "SM-2" # 主要算法; 可选项: SM-2, SM-15M, FSRS default = "SM-2" # 主要算法; 可选项: SM-2, SM-15M, FSRS

View File

@@ -1,5 +1,11 @@
schedule = ["quick_review", "recognition", "final_review"] schedule = ["quick_review", "recognition", "final_review"]
[phases] [phases]
quick_review = [["FillBlank", "1.0"], ["SelectMeaning", "0.5"], ["Recognition", "1.0"]] quick_review = [["FillBlank", "1.0"], ["SelectMeaning", "0.5"], ["Recognition", "1.0"]]
recognition = [["Recognition", "1.0"]] recognition = [["Recognition", "1.0"]]
final_review = [["FillBlank", "0.7"], ["SelectMeaning", "0.7"], ["Recognition", "1.0"]] final_review = [["FillBlank", "1.0"], ["SelectMeaning", "1.0"], ["Recognition", "1.0"]]
[annotation]
"quick_review" = "复习旧知"
"recognition" = "新知识"
"final_review" = "总复习"

View File

@@ -1,56 +0,0 @@
# [调试] 将更改保存到文件
persist_to_file = 1
# [调试] 覆写时间, 设为 -1 以禁用
daystamp_override = -1
timestamp_override = -1
# [调试] 一键通过
quick_pass = 1
# 对于每个项目的默认新记忆原子数量
scheduled_num = 8
# UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒
timezone_offset = +28800 # 中国标准时间 (UTC+8)
[interface]
[interface.memorizor]
autovoice = true # 自动语音播放, 仅限于 recognition 组件
[algorithm]
default = "SM-2" # 主要算法; 可选项: SM-2, SM-15M, FSRS
[puzzles] # 谜题默认配置
[puzzles.mcq]
max_riddles_num = 2
[puzzles.cloze]
min_denominator = 3
[paths] # 相对于配置文件的 ".." (即工作目录) 而言 或绝对路径
data = "./data"
[services] # 定义服务到提供者的映射
audio = "playsound" # 可选项: playsound(通用), termux(仅用于支持 Android Termux), mpg123(TODO)
tts = "edgetts" # 可选项: edgetts
llm = "openai" # 可选项: openai
sync = "webdav" # 可选项: 留空, webdav
[providers.tts.edgetts] # EdgeTTS 设置
voice = "zh-CN-XiaoxiaoNeural" # 可选项: zh-CN-YunjianNeural (男声), zh-CN-XiaoxiaoNeural (女声)
[providers.llm.openai] # 与 OpenAI 相容的语言模型接口服务设置
url = ""
key = ""
[providers.sync.webdav] # WebDAV 同步设置
url = ""
username = ""
password = ""
remote_path = "/heurams/"
verify_ssl = true
[sync]

View File

@@ -6,7 +6,8 @@
"metadata": {}, "metadata": {},
"source": [ "source": [
"# 演练场\n", "# 演练场\n",
"此笔记本将带你了解 repomgr 与 particles 对象相关操作" "此笔记本将带你了解 repomgr 与 particles 对象相关操作 \n",
"此笔记本内含的系统命令默认仅存在于 Linux 操作系统, 如果你使用 Windows, 请在安装 busybox 或 cygwin 或 WSL 的环境下执行此笔记本"
] ]
}, },
{ {
@@ -21,7 +22,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 1, "execution_count": 29,
"id": "a5ed9864", "id": "a5ed9864",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -30,7 +31,12 @@
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"\u001b[01;34m.\u001b[0m\n", "\u001b[01;34m.\u001b[0m\n",
"├── \u001b[01;34mdata\u001b[0m\n",
"│   └── \u001b[01;34mconfig\u001b[0m\n",
"│   └── \u001b[00mconfig.toml\u001b[0m\n",
"├── \u001b[00mjiebatest.py\u001b[0m\n",
"├── \u001b[00mrepo.ipynb\u001b[0m\n", "├── \u001b[00mrepo.ipynb\u001b[0m\n",
"├── \u001b[00msimplemem.py\u001b[0m\n",
"└── \u001b[01;34mtest_repo\u001b[0m\n", "└── \u001b[01;34mtest_repo\u001b[0m\n",
" ├── \u001b[00malgodata.json\u001b[0m\n", " ├── \u001b[00malgodata.json\u001b[0m\n",
" ├── \u001b[00mmanifest.toml\u001b[0m\n", " ├── \u001b[00mmanifest.toml\u001b[0m\n",
@@ -38,7 +44,7 @@
" ├── \u001b[00mschedule.toml\u001b[0m\n", " ├── \u001b[00mschedule.toml\u001b[0m\n",
" └── \u001b[00mtypedef.toml\u001b[0m\n", " └── \u001b[00mtypedef.toml\u001b[0m\n",
"\n", "\n",
"2 directories, 6 files\n" "4 directories, 9 files\n"
] ]
} }
], ],
@@ -56,7 +62,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 2, "execution_count": 30,
"id": "9777730e", "id": "9777730e",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -85,21 +91,10 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 3, "execution_count": 31,
"id": "bf1b00c8", "id": "bf1b00c8",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [],
{
"name": "stdout",
"output_type": "stream",
"text": [
"欢迎使用 HeurAMS 及其组件!\n",
"rootdir: /mnt/data/Devel/HeurAMS/HeurAMS/src/heurams\n",
"workdir: /mnt/data/Devel/HeurAMS/HeurAMS/examples\n",
"未能加载自定义用户配置\n"
]
}
],
"source": [ "source": [
"import heurams.kernel.repolib as repolib # 这是 RepoLib 子模块, 用于管理和结构化 repo(中文含义: 仓库) 数据结构与本地文件间的联系\n", "import heurams.kernel.repolib as repolib # 这是 RepoLib 子模块, 用于管理和结构化 repo(中文含义: 仓库) 数据结构与本地文件间的联系\n",
"import heurams.kernel.particles as pt # 这是 Particles(中文含义: 粒子) 子模块, 用于运行时的记忆管理操作\n", "import heurams.kernel.particles as pt # 这是 Particles(中文含义: 粒子) 子模块, 用于运行时的记忆管理操作\n",
@@ -120,7 +115,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 4, "execution_count": 32,
"id": "897b62d7", "id": "897b62d7",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -148,7 +143,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 5, "execution_count": 33,
"id": "708ae7e4", "id": "708ae7e4",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
@@ -169,7 +164,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 6, "execution_count": 34,
"id": "a11115fb", "id": "a11115fb",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -177,20 +172,20 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"{'algodata': [('君臣固守以窥周室,', {}), ('秦孝公据崤函之固, 拥雍州之地,', {})],\n", "{'algodata': [('秦孝公据崤函之固, 拥雍州之地,', {}), ('君臣固守以窥周室,', {})],\n",
" 'manifest': {'author': '__heurams__',\n", " 'manifest': {'author': '__heurams__',\n",
" 'desc': '高考古诗文: 过秦论',\n", " 'desc': '高考古诗文: 过秦论',\n",
" 'title': '测试单元: 过秦论'},\n", " 'title': '测试单元: 过秦论'},\n",
" 'payload': [('君臣固守以窥周室,',\n", " 'payload': [('秦孝公据崤函之固, 拥雍州之地,',\n",
" {'content': '君臣/固守/以窥/周室,/',\n",
" 'keyword_note': {'窥': '窥视'},\n",
" 'note': [],\n",
" 'translation': '君臣牢固地守卫着,借以窥视周王室的权力,'}),\n",
" ('秦孝公据崤函之固, 拥雍州之地,',\n",
" {'content': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/',\n", " {'content': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/',\n",
" 'keyword_note': {'崤函': '崤山和函谷关', '据': '占据', '雍州': '古代九州之一'},\n", " 'keyword_note': {'崤函': '崤山和函谷关', '据': '占据', '雍州': '古代九州之一'},\n",
" 'note': [],\n", " 'note': [],\n",
" 'translation': '秦孝公占据着崤山和函谷关的险固地势,拥有雍州的土地,'})],\n", " 'translation': '秦孝公占据着崤山和函谷关的险固地势,拥有雍州的土地,'}),\n",
" ('君臣固守以窥周室,',\n",
" {'content': '君臣/固守/以窥/周室,/',\n",
" 'keyword_note': {'窥': '窥视'},\n",
" 'note': [],\n",
" 'translation': '君臣牢固地守卫着,借以窥视周王室的权力,'})],\n",
" 'schedule': {'phases': {'final_review': [['FillBlank', '0.7'],\n", " 'schedule': {'phases': {'final_review': [['FillBlank', '0.7'],\n",
" ['SelectMeaning', '0.7'],\n", " ['SelectMeaning', '0.7'],\n",
" ['Recognition', '1.0']],\n", " ['Recognition', '1.0']],\n",
@@ -207,26 +202,26 @@
" 'translation': '语句翻译',\n", " 'translation': '语句翻译',\n",
" 'tts_text': '文本转语音文本'},\n", " 'tts_text': '文本转语音文本'},\n",
" 'common': {'delimiter': '/',\n", " 'common': {'delimiter': '/',\n",
" 'tts_text': \"eval:payload['content'].replace('/', '')\"},\n", " 'puzzles': {'FillBlank': {'__hint__': '',\n",
" 'puzzles': {'FillBlank': {'__hint__': '',\n", " '__origin__': 'cloze',\n",
" '__origin__': 'cloze',\n", " 'delimiter': \"eval:nucleon['delimiter']\",\n",
" 'delimiter': \"eval:metadata['formation']['delimiter']\",\n", " 'min_denominator': \"eval:default['cloze']['min_denominator']\",\n",
" 'min_denominator': \"eval:default['cloze']['min_denominator']\",\n", " 'text': \"eval:payload['content']\"},\n",
" 'text': \"eval:payload['content']\"},\n", " 'Recognition': {'__hint__': '',\n",
" 'Recognition': {'__hint__': '',\n", " '__origin__': 'recognition',\n",
" '__origin__': 'recognition',\n", " 'primary': \"eval:payload['content']\",\n",
" 'primary': \"eval:payload['content']\",\n", " 'secondary': [\"eval:payload['keyword_note']\",\n",
" 'secondary': [\"eval:payload['keyword_note']\",\n", " \"eval:payload['note']\"],\n",
" \"eval:payload['note']\"],\n", " 'top_dim': [\"eval:payload['translation']\"]},\n",
" 'top_dim': [\"eval:payload['translation']\"]},\n", " 'SelectMeaning': {'__hint__': \"eval:payload['content']\",\n",
" 'SelectMeaning': {'__hint__': \"eval:payload['content']\",\n", " '__origin__': 'mcq',\n",
" '__origin__': 'mcq',\n", " 'jammer': \"eval:list(payload['keyword_note'].values())\",\n",
" 'jammer': \"eval:list(payload['keyword_note'].values())\",\n", " 'mapping': \"eval:payload['keyword_note']\",\n",
" 'mapping': \"eval:payload['keyword_note']\",\n", " 'max_riddles_num': \"eval:default['mcq']['max_riddles_num']\",\n",
" 'max_riddles_num': \"eval:default['mcq']['max_riddles_num']\",\n", " 'prefix': '选择正确项: ',\n",
" 'prefix': '选择正确项: ',\n", " 'primary': \"eval:payload['content']\"}},\n",
" 'primary': \"eval:payload['content']\"}},\n", " 'tts_text': \"eval:payload['content'].replace('/', \"\n",
" '古文句': {}}}\n" " \"'')\"}}}\n"
] ]
} }
], ],
@@ -255,13 +250,13 @@
"- save_list: 默认为 [\"algodata\"], 是要持久化的数据.\n", "- save_list: 默认为 [\"algodata\"], 是要持久化的数据.\n",
"- source: 默认为原目录, 你也可以手动指定为其他文件夹(通过 Path)\n", "- source: 默认为原目录, 你也可以手动指定为其他文件夹(通过 Path)\n",
"\n", "\n",
"现在做一些演练, 我们将创建一个位于 test_new_repo 的\"克隆\", 此时我们!\n", "现在做一些演练, 我们将创建一个位于 test_new_repo 的\"克隆\". \n",
"除非文件夹已经存在, Repo 对象将会为你自动创建新文件夹." "除非文件夹已经存在, Repo 对象将会为你自动创建新文件夹."
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 7, "execution_count": 35,
"id": "05eeaacc", "id": "05eeaacc",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -270,8 +265,12 @@
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"\u001b[01;34m.\u001b[0m\n", "\u001b[01;34m.\u001b[0m\n",
"├── \u001b[00mheurams.log\u001b[0m\n", "├── \u001b[01;34mdata\u001b[0m\n",
"│   └── \u001b[01;34mconfig\u001b[0m\n",
"│   └── \u001b[00mconfig.toml\u001b[0m\n",
"├── \u001b[00mjiebatest.py\u001b[0m\n",
"├── \u001b[00mrepo.ipynb\u001b[0m\n", "├── \u001b[00mrepo.ipynb\u001b[0m\n",
"├── \u001b[00msimplemem.py\u001b[0m\n",
"├── \u001b[01;34mtest_new_repo\u001b[0m\n", "├── \u001b[01;34mtest_new_repo\u001b[0m\n",
"│   ├── \u001b[00malgodata.json\u001b[0m\n", "│   ├── \u001b[00malgodata.json\u001b[0m\n",
"│   ├── \u001b[00mmanifest.toml\u001b[0m\n", "│   ├── \u001b[00mmanifest.toml\u001b[0m\n",
@@ -285,7 +284,7 @@
" ├── \u001b[00mschedule.toml\u001b[0m\n", " ├── \u001b[00mschedule.toml\u001b[0m\n",
" └── \u001b[00mtypedef.toml\u001b[0m\n", " └── \u001b[00mtypedef.toml\u001b[0m\n",
"\n", "\n",
"3 directories, 12 files\n" "5 directories, 14 files\n"
] ]
} }
], ],
@@ -327,7 +326,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 8, "execution_count": 36,
"id": "7e88bd7c", "id": "7e88bd7c",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -335,13 +334,13 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"[('age', 12), ('enemy', 'jerry'), ('name', 'tom')]\n", "[('name', 'tom'), ('age', 12), ('enemy', 'jerry')]\n",
"[('age', 12), ('enemy', 'jerry'), ('name', 'tom')]\n" "[('name', 'tom'), ('age', 12), ('enemy', 'jerry')]\n"
] ]
} }
], ],
"source": [ "source": [
"from heurams.utils.lict import Lict\n", "from heurams.kernel.auxiliary.lict import Lict\n",
"\n", "\n",
"lct = Lict() # 空的\n", "lct = Lict() # 空的\n",
"lct = Lict(initlist=[(\"name\", \"tom\"), (\"age\", 12), (\"enemy\", \"jerry\")]) # 基于列表\n", "lct = Lict(initlist=[(\"name\", \"tom\"), (\"age\", 12), (\"enemy\", \"jerry\")]) # 基于列表\n",
@@ -356,13 +355,13 @@
"metadata": {}, "metadata": {},
"source": [ "source": [
"### 输出形式\n", "### 输出形式\n",
"lct 的\"官方\"输出形式是列表形式\n", "Lict 的\"官方\"输出形式是列表形式\n",
"你也可以选择输出字典形式" "你也可以选择输出字典形式"
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 9, "execution_count": 37,
"id": "248f6cba", "id": "248f6cba",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -392,7 +391,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 10, "execution_count": 38,
"id": "a0eb07a7", "id": "a0eb07a7",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -400,9 +399,9 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"[('age', 12), ('enemy', 'jerry'), ('name', 'tom')]\n", "[('name', 'tom'), ('age', 12), ('enemy', 'jerry')]\n",
"[('age', 12), ('enemy', 'jerry'), ('name', 'tom'), ('type', 'cat')]\n", "[('name', 'tom'), ('age', 12), ('enemy', 'jerry'), ('type', 'cat')]\n",
"[('age', 12), ('enemy', 'jerry'), ('is_human', False), ('name', 'tom'), ('type', 'cat')]\n" "[('name', 'tom'), ('age', 12), ('enemy', 'jerry'), ('type', 'cat'), ('is_human', False)]\n"
] ]
} }
], ],
@@ -437,7 +436,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 11, "execution_count": null,
"id": "0ab442d4", "id": "0ab442d4",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -445,12 +444,12 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"{'age': 12, 'enemy': 'jerry', 'is_human': False, 'name': 'tom', 'type': 'cat', 'enemy_2': 'spike'}\n" "{'name': 'tom', 'age': 12, 'enemy': 'jerry', 'type': 'cat', 'is_human': False, 'enemy_2': 'spike'}\n"
] ]
} }
], ],
"source": [ "source": [
"# 由于 jupyter 的环境处理, 请不要重复运行此单元格, 如果想再看一遍, 请重启 jupyter 后再全部运行\n", "# 由于 Jupyter 的环境处理(环境状态会累积), 请不要重复运行此单元格, 如果想再看一遍, 请重启 jupyter 后再全部运行\n",
"\n", "\n",
"# 唯一推荐方式\n", "# 唯一推荐方式\n",
"lct.append((\"enemy_2\", \"spike\"))\n", "lct.append((\"enemy_2\", \"spike\"))\n",
@@ -470,7 +469,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 12, "execution_count": 40,
"id": "f3ca752f", "id": "f3ca752f",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -478,23 +477,23 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"[('age', 12), ('enemy', 'jerry'), ('enemy_2', 'spike'), ('is_human', False), ('name', 'tom'), ('type', 'cat')]\n", "[('age', 12), ('enemy', 'jerry'), ('is_human', False), ('name', 'tom'), ('type', 'cat'), ('enemy_2', 'spike')]\n",
"{'age': 12, 'enemy': 'jerry', 'is_human': False, 'name': 'tom', 'type': 'cat', 'enemy_2': 'spike'}\n", "{'age': 12, 'enemy': 'jerry', 'is_human': False, 'name': 'tom', 'type': 'cat', 'enemy_2': 'spike'}\n",
"------\n", "------\n",
"('age', 12)\n", "('age', 12)\n",
"('enemy', 'jerry')\n", "('enemy', 'jerry')\n",
"('enemy_2', 'spike')\n",
"('is_human', False)\n", "('is_human', False)\n",
"('name', 'tom')\n", "('name', 'tom')\n",
"('type', 'cat')\n", "('type', 'cat')\n",
"('enemy_2', 'spike')\n",
"6\n", "6\n",
"('type', 'cat')\n",
"[('age', 12), ('enemy', 'jerry'), ('enemy_2', 'spike'), ('is_human', False), ('name', 'tom')]\n",
"('name', 'tom')\n",
"[('age', 12), ('enemy', 'jerry'), ('enemy_2', 'spike'), ('is_human', False)]\n",
"('is_human', False)\n",
"[('age', 12), ('enemy', 'jerry'), ('enemy_2', 'spike')]\n",
"('enemy_2', 'spike')\n", "('enemy_2', 'spike')\n",
"[('age', 12), ('enemy', 'jerry'), ('is_human', False), ('name', 'tom'), ('type', 'cat')]\n",
"('type', 'cat')\n",
"[('age', 12), ('enemy', 'jerry'), ('is_human', False), ('name', 'tom')]\n",
"('name', 'tom')\n",
"[('age', 12), ('enemy', 'jerry'), ('is_human', False)]\n",
"('is_human', False)\n",
"[('age', 12), ('enemy', 'jerry')]\n", "[('age', 12), ('enemy', 'jerry')]\n",
"('enemy', 'jerry')\n", "('enemy', 'jerry')\n",
"[('age', 12)]\n", "[('age', 12)]\n",
@@ -508,7 +507,7 @@
"Ellipsis" "Ellipsis"
] ]
}, },
"execution_count": 12, "execution_count": 40,
"metadata": {}, "metadata": {},
"output_type": "execute_result" "output_type": "execute_result"
} }
@@ -556,10 +555,18 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 13, "execution_count": 41,
"id": "773bf99c", "id": "773bf99c",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"zsh:1: no matches found: heurams.log*\n"
]
}
],
"source": [ "source": [
"!rm -rf test_new_repo\n", "!rm -rf test_new_repo\n",
"!rm -rf heurams.log*" "!rm -rf heurams.log*"
@@ -567,7 +574,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 14, "execution_count": 42,
"id": "8645c5a2", "id": "8645c5a2",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -575,67 +582,106 @@
"name": "stdout", "name": "stdout",
"output_type": "stream", "output_type": "stream",
"text": [ "text": [
"{ 'content': '君臣/固守/以窥/周室,/',\n",
" 'delimiter': '/',\n",
" 'keyword_note': {'窥': '窥视'},\n",
" 'note': [],\n",
" 'translation': '君臣牢固地守卫着,借以窥视周王室的权力,',\n",
" 'tts_text': '君臣固守以窥周室,'}\n",
"{ 'SM-2': { 'efactor': 2.5,\n",
" 'interval': 1,\n",
" 'is_activated': 1,\n",
" 'last_date': 20454,\n",
" 'last_modify': 1767274438.752494,\n",
" 'next_date': 20455,\n",
" 'real_rept': 1,\n",
" 'rept': 0}}\n",
"{ 'content': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/',\n", "{ 'content': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/',\n",
" 'delimiter': '/',\n", " 'delimiter': '/',\n",
" 'keyword_note': {'崤函': '崤山和函谷关', '据': '占据', '雍州': '古代九州之一'},\n", " 'keyword_note': {'崤函': '崤山和函谷关', '据': '占据', '雍州': '古代九州之一'},\n",
" 'note': [],\n", " 'note': [],\n",
" 'puzzles': { 'FillBlank': { '__hint__': '',\n",
" '__origin__': 'cloze',\n",
" 'delimiter': '/',\n",
" 'min_denominator': 3,\n",
" 'text': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/'},\n",
" 'Recognition': { '__hint__': '',\n",
" '__origin__': 'recognition',\n",
" 'primary': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/',\n",
" 'secondary': [ { '崤函': '崤山和函谷关',\n",
" '据': '占据',\n",
" '雍州': '古代九州之一'},\n",
" []],\n",
" 'top_dim': [ '秦孝公占据着崤山和函谷关的险固地势,拥有雍州的土地,']},\n",
" 'SelectMeaning': { '__hint__': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/',\n",
" '__origin__': 'mcq',\n",
" 'jammer': ['占据', '崤山和函谷关', '古代九州之一'],\n",
" 'mapping': { '崤函': '崤山和函谷关',\n",
" '据': '占据',\n",
" '雍州': '古代九州之一'},\n",
" 'max_riddles_num': 2,\n",
" 'prefix': '选择正确项: ',\n",
" 'primary': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/'}},\n",
" 'translation': '秦孝公占据着崤山和函谷关的险固地势,拥有雍州的土地,',\n", " 'translation': '秦孝公占据着崤山和函谷关的险固地势,拥有雍州的土地,',\n",
" 'tts_text': '秦孝公据崤函之固, 拥雍州之地,'}\n", " 'tts_text': '秦孝公据崤函之固, 拥雍州之地,'}\n",
"{ 'SM-2': { 'efactor': 2.5,\n", "{ 'SM-2': { 'efactor': 2.5,\n",
" 'interval': 1,\n", " 'interval': 1,\n",
" 'is_activated': 1,\n", " 'is_activated': 1,\n",
" 'last_date': 20454,\n", " 'last_date': 20459,\n",
" 'last_modify': 1767274438.7534873,\n", " 'last_modify': 1767700296.4950516,\n",
" 'next_date': 20455,\n", " 'next_date': 20460,\n",
" 'real_rept': 1,\n", " 'real_rept': 1,\n",
" 'rept': 0}}\n", " 'rept': 0}}\n",
"{ 'algodata': [ ( '君臣固守以窥周室,',\n", "{ 'content': '君臣/固守/以窥/周室,/',\n",
" 'delimiter': '/',\n",
" 'keyword_note': {'窥': '窥视'},\n",
" 'note': [],\n",
" 'puzzles': { 'FillBlank': { '__hint__': '',\n",
" '__origin__': 'cloze',\n",
" 'delimiter': '/',\n",
" 'min_denominator': 3,\n",
" 'text': '君臣/固守/以窥/周室,/'},\n",
" 'Recognition': { '__hint__': '',\n",
" '__origin__': 'recognition',\n",
" 'primary': '君臣/固守/以窥/周室,/',\n",
" 'secondary': [{'窥': '窥视'}, []],\n",
" 'top_dim': ['君臣牢固地守卫着,借以窥视周王室的权力,']},\n",
" 'SelectMeaning': { '__hint__': '君臣/固守/以窥/周室,/',\n",
" '__origin__': 'mcq',\n",
" 'jammer': ['窥视'],\n",
" 'mapping': {'窥': '窥视'},\n",
" 'max_riddles_num': 2,\n",
" 'prefix': '选择正确项: ',\n",
" 'primary': '君臣/固守/以窥/周室,/'}},\n",
" 'translation': '君臣牢固地守卫着,借以窥视周王室的权力,',\n",
" 'tts_text': '君臣固守以窥周室,'}\n",
"{ 'SM-2': { 'efactor': 2.5,\n",
" 'interval': 1,\n",
" 'is_activated': 1,\n",
" 'last_date': 20459,\n",
" 'last_modify': 1767700296.4968777,\n",
" 'next_date': 20460,\n",
" 'real_rept': 1,\n",
" 'rept': 0}}\n",
"{ 'algodata': [ ( '秦孝公据崤函之固, 拥雍州之地,',\n",
" { 'SM-2': { 'efactor': 2.5,\n", " { 'SM-2': { 'efactor': 2.5,\n",
" 'interval': 1,\n", " 'interval': 1,\n",
" 'is_activated': 1,\n", " 'is_activated': 1,\n",
" 'last_date': 20454,\n", " 'last_date': 20459,\n",
" 'last_modify': 1767274438.752494,\n", " 'last_modify': 1767700296.4950516,\n",
" 'next_date': 20455,\n", " 'next_date': 20460,\n",
" 'real_rept': 1,\n", " 'real_rept': 1,\n",
" 'rept': 0}}),\n", " 'rept': 0}}),\n",
" ( '秦孝公据崤函之固, 拥雍州之地,',\n", " ( '君臣固守以窥周室,',\n",
" { 'SM-2': { 'efactor': 2.5,\n", " { 'SM-2': { 'efactor': 2.5,\n",
" 'interval': 1,\n", " 'interval': 1,\n",
" 'is_activated': 1,\n", " 'is_activated': 1,\n",
" 'last_date': 20454,\n", " 'last_date': 20459,\n",
" 'last_modify': 1767274438.7534873,\n", " 'last_modify': 1767700296.4968777,\n",
" 'next_date': 20455,\n", " 'next_date': 20460,\n",
" 'real_rept': 1,\n", " 'real_rept': 1,\n",
" 'rept': 0}})],\n", " 'rept': 0}})],\n",
" 'manifest': { 'author': '__heurams__',\n", " 'manifest': { 'author': '__heurams__',\n",
" 'desc': '高考古诗文: 过秦论',\n", " 'desc': '高考古诗文: 过秦论',\n",
" 'title': '测试单元: 过秦论'},\n", " 'title': '测试单元: 过秦论'},\n",
" 'payload': [ ( '君臣固守以窥周室,',\n", " 'payload': [ ( '秦孝公据崤函之固, 拥雍州之地,',\n",
" { 'content': '君臣/固守/以窥/周室,/',\n",
" 'keyword_note': {'窥': '窥视'},\n",
" 'note': [],\n",
" 'translation': '君臣牢固地守卫着,借以窥视周王室的权力,'}),\n",
" ( '秦孝公据崤函之固, 拥雍州之地,',\n",
" { 'content': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/',\n", " { 'content': '秦孝公/据/崤函/之固/, 拥/雍州/之地,/',\n",
" 'keyword_note': { '崤函': '崤山和函谷关',\n", " 'keyword_note': { '崤函': '崤山和函谷关',\n",
" '据': '占据',\n", " '据': '占据',\n",
" '雍州': '古代九州之一'},\n", " '雍州': '古代九州之一'},\n",
" 'note': [],\n", " 'note': [],\n",
" 'translation': '秦孝公占据着崤山和函谷关的险固地势,拥有雍州的土地,'})],\n", " 'translation': '秦孝公占据着崤山和函谷关的险固地势,拥有雍州的土地,'}),\n",
" ( '君臣固守以窥周室,',\n",
" { 'content': '君臣/固守/以窥/周室,/',\n",
" 'keyword_note': {'窥': '窥视'},\n",
" 'note': [],\n",
" 'translation': '君臣牢固地守卫着,借以窥视周王室的权力,'})],\n",
" 'schedule': { 'phases': { 'final_review': [ ['FillBlank', '0.7'],\n", " 'schedule': { 'phases': { 'final_review': [ ['FillBlank', '0.7'],\n",
" ['SelectMeaning', '0.7'],\n", " ['SelectMeaning', '0.7'],\n",
" ['Recognition', '1.0']],\n", " ['Recognition', '1.0']],\n",
@@ -654,27 +700,26 @@
" 'translation': '语句翻译',\n", " 'translation': '语句翻译',\n",
" 'tts_text': '文本转语音文本'},\n", " 'tts_text': '文本转语音文本'},\n",
" 'common': { 'delimiter': '/',\n", " 'common': { 'delimiter': '/',\n",
" 'puzzles': { 'FillBlank': { '__hint__': '',\n",
" '__origin__': 'cloze',\n",
" 'delimiter': \"eval:nucleon['delimiter']\",\n",
" 'min_denominator': \"eval:default['cloze']['min_denominator']\",\n",
" 'text': \"eval:payload['content']\"},\n",
" 'Recognition': { '__hint__': '',\n",
" '__origin__': 'recognition',\n",
" 'primary': \"eval:payload['content']\",\n",
" 'secondary': [ \"eval:payload['keyword_note']\",\n",
" \"eval:payload['note']\"],\n",
" 'top_dim': [ \"eval:payload['translation']\"]},\n",
" 'SelectMeaning': { '__hint__': \"eval:payload['content']\",\n",
" '__origin__': 'mcq',\n",
" 'jammer': \"eval:list(payload['keyword_note'].values())\",\n",
" 'mapping': \"eval:payload['keyword_note']\",\n",
" 'max_riddles_num': \"eval:default['mcq']['max_riddles_num']\",\n",
" 'prefix': '选择正确项: ',\n",
" 'primary': \"eval:payload['content']\"}},\n",
" 'tts_text': \"eval:payload['content'].replace('/', \"\n", " 'tts_text': \"eval:payload['content'].replace('/', \"\n",
" \"'')\"},\n", " \"'')\"}}}\n"
" 'puzzles': { 'FillBlank': { '__hint__': '',\n",
" '__origin__': 'cloze',\n",
" 'delimiter': \"eval:metadata['formation']['delimiter']\",\n",
" 'min_denominator': \"eval:default['cloze']['min_denominator']\",\n",
" 'text': \"eval:payload['content']\"},\n",
" 'Recognition': { '__hint__': '',\n",
" '__origin__': 'recognition',\n",
" 'primary': \"eval:payload['content']\",\n",
" 'secondary': [ \"eval:payload['keyword_note']\",\n",
" \"eval:payload['note']\"],\n",
" 'top_dim': [ \"eval:payload['translation']\"]},\n",
" 'SelectMeaning': { '__hint__': \"eval:payload['content']\",\n",
" '__origin__': 'mcq',\n",
" 'jammer': \"eval:list(payload['keyword_note'].values())\",\n",
" 'mapping': \"eval:payload['keyword_note']\",\n",
" 'max_riddles_num': \"eval:default['mcq']['max_riddles_num']\",\n",
" 'prefix': '选择正确项: ',\n",
" 'primary': \"eval:payload['content']\"}},\n",
" '古文句': {}}}\n"
] ]
} }
], ],

View File

@@ -1,8 +1,10 @@
import heurams.kernel.repolib as repolib
import heurams.kernel.particles as pt
from heurams.services.textproc import truncate
from pathlib import Path
import time import time
from pathlib import Path
import heurams.kernel.particles as pt
import heurams.kernel.repolib as repolib
from heurams.services.textproc import truncate
repo = repolib.Repo.create_from_repodir(Path("./test_repo")) repo = repolib.Repo.create_from_repodir(Path("./test_repo"))
alist = list() alist = list()
print(repo.ident_index) print(repo.ident_index)
@@ -17,38 +19,39 @@ for i in repo.ident_index:
input() input()
a = pt.Atom(n, e, repo.orbitic_data) a = pt.Atom(n, e, repo.orbitic_data)
alist.append(a) alist.append(a)
#e.activate() # e.activate()
#e.revisor(5, True) # e.revisor(5, True)
print(repr(a)) print(repr(a))
# print(repr(e)) # print(repr(e))
print(repo) print(repo)
input() input()
import heurams.kernel.reactor as rt import heurams.kernel.reactor as rt
ph: rt.Phaser = rt.Phaser(alist) ph: rt.Phaser = rt.Phaser(alist)
print(ph) print(ph)
pr: rt.Procession = ph.current_procession() # type: ignore pr: rt.Procession = ph.current_procession() # type: ignore
print(pr) print(pr)
pr.forward() pr.forward()
print(pr) print(pr)
pr.forward() # 如果过界了? pr.forward() # 如果过界了?
print(pr) # 静默设置状态 无报错 print(pr) # 静默设置状态 无报错
pr.forward() pr.forward()
print(pr) print(pr)
pr = ph.current_procession() # type: ignore # 下一个队列 pr = ph.current_procession() # type: ignore # 下一个队列
print(pr) print(pr)
pr.forward() pr.forward()
print(pr) print(pr)
pr.append() # 如果记忆失败了? pr.append() # 如果记忆失败了?
print(pr) print(pr)
pr.forward() pr.forward()
pr.append() # 如果记忆失败了? pr.append() # 如果记忆失败了?
pr.append() # 如果记忆失败了? pr.append() # 如果记忆失败了?
pr.append() # 如果记忆失败了? pr.append() # 如果记忆失败了?
pr.append() # 如果记忆失败了? pr.append() # 如果记忆失败了?
pr.append() # 如果记忆失败了? pr.append() # 如果记忆失败了?
# 重复项目只会占据一个车尾 # 重复项目只会占据一个车尾
print(pr) print(pr)
pr.forward() pr.forward()
print(pr) print(pr)
pr = ph.current_procession() # type: ignore pr = ph.current_procession() # type: ignore
print(pr) print(pr)

12
glossary.md Normal file
View File

@@ -0,0 +1,12 @@
# 运行时对象
Atom: 原子, 由核子, 电子, 轨道对象一并构成, 用于处理记忆所需一系列对象
Nucleon: 核子, 负责解析文件动态内容, 并储存记忆材料内容与谜题定义, 是静态只读但可临时覆盖内容的
Electron: 电子, 负责处理记忆算法数据
Orbital: 轨道, 储存记忆阶段信息与谜题阶段内出现配置
# 状态机对象
Transitions: 一种状态机框架库
Reactor: 状态机库
Phaser...
rating: 用户评估生成的值
quality: 用于单元反馈的值

View File

@@ -1,28 +1,34 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project] [project]
name = "heurams" name = "heurams"
version = "0.5.0" version = "0.5.0"
description = "Heuristic Assisted Memory Scheduler" description = "Heuristic Auxiliary Memory Scheduler"
license = {file = "LICENSE"}
classifiers = [
"License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
"Topic :: Education",
"Intended Audience :: Education",
]
keywords = ["spaced-repetition", "memory", "learning", "tui", "textual", "flashcards", "education"]
dependencies = [
"bidict==0.23.1",
"playsound==1.2.2",
"textual==5.3.0",
"toml==0.10.2",
]
readme = "README.md" readme = "README.md"
authors = [
{ name = "pluvium27", email = "pluvium27@outlook.com" }
]
requires-python = ">=3.12"
dependencies = [
"edge-tts==7.0.2",
"jieba==0.42.1",
"openai==1.0.0",
"playsound==1.2.2",
"psutil>=7.2.1",
"pygobject>=3.54.5",
"tabulate>=0.9.0",
"textual==7.0.0",
"toml==0.10.2",
"transitions==0.9.3",
]
[tool.setuptools.packages.find] [project.scripts]
where = ["src"] heurams = "heurams.__main__:main"
tui = "heurams.interface.__main__:main"
[build-system]
requires = ["uv_build>=0.9.22,<0.10.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"flet>=0.80.1",
]

View File

@@ -1,6 +1,8 @@
bidict==0.23.1 edge-tts==7.0.2
jieba==0.42.1
openai==1.0.0
playsound==1.2.2 playsound==1.2.2
textual==5.3.0 tabulate>=0.9.0
textual==7.0.0
toml==0.10.2 toml==0.10.2
requests>=2.31.0 transitions==0.9.3
webdavclient3>=3.0.0

View File

@@ -1,7 +1,21 @@
prompt = """HeurAMS 已经被成功地安装在系统中. import heurams.services.version as ver
但 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()

View File

@@ -5,7 +5,6 @@
import pathlib import pathlib
from contextvars import ContextVar from contextvars import ContextVar
import shutil
from heurams.services.config import ConfigFile from heurams.services.config import ConfigFile
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
@@ -15,40 +14,19 @@ from heurams.services.logger import get_logger
# 数据文件路径规定: 以运行目录为准 # 数据文件路径规定: 以运行目录为准
rootdir = pathlib.Path(__file__).parent rootdir = pathlib.Path(__file__).parent
print(f"项目根目录: {rootdir}") workdir = pathlib.Path.cwd()
#print(f"项目根目录: {rootdir}")
#print(f"工作目录: {workdir}")
logger = get_logger(__name__) logger = get_logger(__name__)
logger.debug(f"项目根目录: {rootdir}") logger.debug(f"项目根目录: {rootdir}")
workdir = pathlib.Path.cwd()
print(f"工作目录: {workdir}")
logger.debug(f"工作目录: {workdir}") logger.debug(f"工作目录: {workdir}")
if pathlib.Path(workdir / "data" / "config" / "config_dev.toml").exists(): (workdir / "data" / "config").mkdir(parents=True, exist_ok=True)
print("使用开发设置")
logger.debug("使用开发设置")
config_var: ContextVar[ConfigFile] = ContextVar(
"config_var",
default=ConfigFile(workdir / "data" / "config" / "config_dev.toml"),
)
else:
try:
config_var: ContextVar[ConfigFile] = ContextVar(
"config_var",
default=ConfigFile(workdir / "data" / "config" / "config.toml"),
) # 配置文件
except Exception as e:
input("按下回车以创建新的配置文件, 或按下 Ctrl + C 以终止程序 ")
(workdir / "data" / "config").mkdir(parents=True, exist_ok=True)
(workdir / "data" / "config" / "config").unlink(missing_ok=True)
shutil.copy(
(rootdir / "default" / "config" / "config.toml"),
workdir / "data" / "config" / "config.toml",
)
finally:
config_var: ContextVar[ConfigFile] = ContextVar(
"config_var",
default=ConfigFile(workdir / "data" / "config" / "config.toml"),
) # 配置文件
config_var: ContextVar[ConfigFile] = ContextVar(
"config_var",
default=ConfigFile(workdir / "data" / "config" / "config.toml"),
)
class ConfigContext: class ConfigContext:
""" """

View File

@@ -1,56 +0,0 @@
# [调试] 将更改保存到文件
persist_to_file = 1
# [调试] 覆写时间, 设为 -1 以禁用
daystamp_override = -1
timestamp_override = -1
# [调试] 一键通过
quick_pass = 1
# 对于每个项目的默认新记忆原子数量
scheduled_num = 8
# UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒
timezone_offset = +28800 # 中国标准时间 (UTC+8)
[interface]
[interface.memorizor]
autovoice = true # 自动语音播放, 仅限于 recognition 组件
[algorithm]
default = "SM-2" # 主要算法; 可选项: SM-2, SM-15M, FSRS
[puzzles] # 谜题默认配置
[puzzles.mcq]
max_riddles_num = 2
[puzzles.cloze]
min_denominator = 3
[paths] # 相对于配置文件的 ".." (即工作目录) 而言 或绝对路径
data = "./data"
[services] # 定义服务到提供者的映射
audio = "playsound" # 可选项: playsound(通用), termux(仅用于支持 Android Termux), mpg123(TODO)
tts = "edgetts" # 可选项: edgetts
llm = "openai" # 可选项: openai
sync = "webdav" # 可选项: 留空, webdav
[providers.tts.edgetts] # EdgeTTS 设置
voice = "zh-CN-XiaoxiaoNeural" # 可选项: zh-CN-YunjianNeural (男声), zh-CN-XiaoxiaoNeural (女声)
[providers.llm.openai] # 与 OpenAI 相容的语言模型接口服务设置
url = ""
key = ""
[providers.sync.webdav] # WebDAV 同步设置
url = ""
username = ""
password = ""
remote_path = "/heurams/"
verify_ssl = true
[sync]

View File

@@ -1,47 +1,46 @@
from time import sleep, perf_counter
print("欢迎使用基本用户界面!")
print("加载配置与上下文... ", end="", flush=True)
_start1 = perf_counter()
_start = perf_counter()
from heurams.context import *
_end = perf_counter()
print(f"已完成! (耗时: {round(1000 * (_end - _start))}ms)")
print("加载用户界面框架... ", end="", flush=True)
_start = perf_counter()
from textual.app import App from textual.app import App
from textual.widgets import Button from textual.widgets import Button
_end = perf_counter()
print(f"已完成! (耗时: {round(1000 * (_end - _start))}ms)")
from heurams.context import config_var print("加载用户界面布局... ", end="", flush=True)
from heurams.services.logger import get_logger _start = perf_counter()
from .screens.about import AboutScreen from .screens.about import AboutScreen
from .screens.dashboard import DashboardScreen from .screens.dashboard import DashboardScreen
from .screens.llmchat import LLMChatScreen
from .screens.navigator import NavigatorScreen
from .screens.precache import PrecachingScreen from .screens.precache import PrecachingScreen
from .screens.radio import RadioScreen
from .screens.repocreator import RepoCreatorScreen from .screens.repocreator import RepoCreatorScreen
from .screens.repoeditor import RepoEditorScreen
from .screens.synctool import SyncScreen from .screens.synctool import SyncScreen
_end = perf_counter()
print(f"已完成! (耗时: {round(1000 * (_end - _start))}ms)")
logger = get_logger(__name__) print(f"组件目录: {rootdir}")
print(f"工作目录: {workdir}")
_end1 = perf_counter()
def environment_check(): print(f"前置工作共计耗时: {round(1000 * (_end1 - _start1))}ms")
from pathlib import Path
logger.debug("检查环境路径")
subdir = ['cache/voice', 'repo', 'global', 'config']
for i in subdir:
i = Path(config_var.get()['paths']['data']) / i
if not i.exists():
logger.info("创建目录: %s", i)
print(f"创建 {i}")
i.mkdir(exist_ok=True, parents=True)
else:
logger.debug("目录已存在: %s", i)
print(f"找到 {i}")
logger.debug("环境检查完成")
class HeurAMSApp(App): class HeurAMSApp(App):
TITLE = "潜进" TITLE = "潜进"
CSS_PATH = "css/main.tcss" CSS_PATH = "css/main.tcss"
SUB_TITLE = "启发式辅助记忆调度器" SUB_TITLE = "启发式辅助记忆调度器"
BINDINGS = [ BINDINGS = [
("q", "quit", "退出"), ("q", "go_back", "退出"),
("d", "toggle_dark", "切换色调"), ("d", "toggle_dark", "主题"),
("1", "app.push_screen('dashboard')", "仪表盘"), ("n", "app.push_screen('navigator')", "导航"),
("2", "app.push_screen('precache_all')", "缓存管理器"), ("z", "app.push_screen('about')", "关于"),
("3", "app.push_screen('repo_creator')", "创建新仓库"),
# ("4", "app.push_screen('synctool')", "同步工具"),
("0", "app.push_screen('about')", "版本信息"),
] ]
SCREENS = { SCREENS = {
"dashboard": DashboardScreen, "dashboard": DashboardScreen,
@@ -49,15 +48,22 @@ class HeurAMSApp(App):
"precache_all": PrecachingScreen, "precache_all": PrecachingScreen,
"synctool": SyncScreen, "synctool": SyncScreen,
"about": AboutScreen, "about": AboutScreen,
"navigator": NavigatorScreen,
"radio": RadioScreen,
"repo_editor": RepoEditorScreen,
"llmchat": LLMChatScreen,
# "config": ConfigScreen,
} }
def on_mount(self) -> None: def on_mount(self) -> None:
environment_check()
self.push_screen("dashboard") self.push_screen("dashboard")
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
self.exit(event.button.id) pass
# self.exit(event.button.id)
def action_go_back(self) -> None:
quit()
def action_do_nothing(self): def action_do_nothing(self):
print("DO NOTHING")
self.refresh() self.refresh()

View File

@@ -1,18 +1,29 @@
from textual.app import App from heurams.interface import *
from textual.widgets import Button
from heurams.context import config_var from heurams.context import config_var
from heurams.interface import HeurAMSApp
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .screens.about import AboutScreen
from .screens.dashboard import DashboardScreen
from .screens.precache import PrecachingScreen
from .screens.repocreator import RepoCreatorScreen
logger = get_logger(__name__) logger = get_logger(__name__)
app = HeurAMSApp() def environment_check():
from pathlib import Path
logger.debug("检查环境路径")
subdir = ["cache/voice", "repo", "global", "config"]
for i in subdir:
i = Path(config_var.get()["paths"]["data"]) / i
if not i.exists():
logger.info("创建目录: %s", i)
print(f"创建 {i}")
i.mkdir(exist_ok=True, parents=True)
else:
logger.debug("目录已存在: %s", i)
print(f"找到 {i}")
logger.debug("环境检查完成")
def main():
environment_check()
app = HeurAMSApp()
app.run(inline=False)
if __name__ == "__main__": if __name__ == "__main__":
app.run() main()

View File

@@ -0,0 +1,87 @@
NavigatorScreen {
align: center middle;
}
.infview {
width: 5fr
}
.dataview {
width: 3fr
}
.repo_listitem {
layout: grid;
grid-size: 2;
}
.repo_listitem_btn {
dock: right;
offset: -5% 0
}
#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;
}
/* LLM 聊天界面样式 */
LLMChatScreen {
background: $surface;
}
#chat-container {
height: 100%;
padding: 1;
}
#toolbar {
height: 3;
margin-bottom: 1;
align: center middle;
}
#toolbar Button {
margin: 0 1;
}
#chat-log {
height: 1fr;
border: solid $primary;
padding: 1;
background: $surface;
}
#dashboardtop {
height: 4
}
#input-container {
height: 3;
margin-top: 1;
align: center middle;
}
#message-input {
width: 1fr;
margin-right: 1;
}
#status-bar {
height: 1;
margin-top: 1;
text-style: italic;
color: $text-muted;
}
.session-label {
color: $primary;
text-style: bold;
}

View File

@@ -7,14 +7,31 @@ from textual.widgets import Button, Footer, Header, Label, Markdown, Static
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", "返回"),
]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
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"""
# 关于 "潜进" # 关于 "潜进"
@@ -22,54 +39,43 @@ class AboutScreen(Screen):
开发代号: {version.codename.capitalize()} {version.codename_cn} 开发代号: {version.codename.capitalize()} {version.codename_cn}
一个基于启发式算法的开放源代码记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划. 一个基于启发式算法的辅助记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划.
以 AGPL-3.0 开放源代码 以 AGPL-3.0 开放源代码, 这直接意味着任何个体直接基于此代码对外或内部提供的应用和服务, 无论本地或网络, 必须向所有用户公开完整修改后的源代码, 且继续沿用 AGPL-3.0 协议.
开发人员: 您可在项目主页 https://ams.imwangzhiyu.xyz 获取用户指南, 开发文档与软件更新.
- Wang Zhiyu([@pluvium27](https://github.com/pluvium27)): 项目作者 如果您觉得这个软件有用, 可以给它添加一个星标 :)
特别感谢: > 此软件, 以及它作为一个"程序库"是自由且免费的, 但是开发工作必须投入大量精力
> 即使您不是软件开发人员, 我们也欢迎您加入 HeurAMS 的队伍!
> 您可以加入各种语言的翻译团队来翻译软件的界面, 您还可以制作图像、主题、音效, 或者改进软件配套的文档……
> 不管您来自何方, 我们都欢迎您加入社区并做出贡献.
> 我们的共同目标是为人人带来高品质的辅助记忆 & 学习软件.
> 您的慷慨支持, 我们必当涌泉相报.
开发人员列表:
- Wang Zhiyu([@pluvium27](https://github.com/pluvium27)): 发起项目与主要维护者
特别感谢以下人士, 他们的算法与理论构成了此软件算法的基石:
- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2 算法与 SM-15 算法理论 - [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SM-2 算法与 SM-15 算法理论
- [Kazuaki Tanida](https://github.com/slaypni): SM-15 算法的 CoffeeScript 实现 - [Kazuaki Tanida](https://github.com/slaypni): SM-15 算法的 CoffeeScript 实现
- [Thoughts Memo](https://www.zhihu.com/people/L.M.Sherlock): 文献参考 - [Thoughts Memo](https://www.zhihu.com/people/L.M.Sherlock): 文献参考
# 参与贡献
我们是一个年轻且包容的社区, 由技术人员, 设计师, 文书工作者, 以及创意人员共同构成, # 运行环境信息
通过我们协力开发的软件为所有人谋取福祉. Python 解释器版本: {python_version}
Textual 框架版本: {textual_version}
上述工作不可避免地让我们确立了下列价值观 (取自 KDE 宣言): 终端模拟器: {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(
@@ -87,3 +93,68 @@ class AboutScreen(Screen):
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 "未知"

View File

@@ -1,12 +1,17 @@
"""仪表盘界面""" """仪表盘界面"""
from functools import reduce
import pathlib import pathlib
from pathlib import Path
import os
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import ScrollableContainer from textual.containers import ScrollableContainer, Container, Horizontal, Vertical
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, Static
from textual.layouts import horizontal
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 *
@@ -15,7 +20,9 @@ from heurams.kernel.repolib import *
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .about import AboutScreen from .about import AboutScreen
from .navigator import NavigatorScreen
from .preparation import PreparationScreen from .preparation import PreparationScreen
from .radio import RadioScreen
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -24,6 +31,9 @@ class DashboardScreen(Screen):
"""主仪表盘屏幕""" """主仪表盘屏幕"""
SUB_TITLE = "仪表盘" SUB_TITLE = "仪表盘"
BINDINGS = [
("q", "go_back", "返回"),
]
def __init__( def __init__(
self, self,
@@ -35,23 +45,39 @@ class DashboardScreen(Screen):
self.repostat = {} self.repostat = {}
self.title2dirname = {} self.title2dirname = {}
self.title2repo = {} self.title2repo = {}
self.dirname2repo = {}
self._load_data() self._load_data()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""组合界面组件""" """组合界面组件"""
yield Header(show_clock=True) yield Header(show_clock=True)
yield ScrollableContainer( with ScrollableContainer():
Label('欢迎使用 "潜进" 启发式辅助记忆调度器', classes="title-label"), yield Horizontal(
Label(f"当前 UNIX 日时间戳: {timer.get_daystamp()} (UTC+{config_var.get()["timezone_offset"] / 3600})"), Vertical(
Label(f"全局算法设置: {config_var.get()['algorithm']['default']}"), Label(f'欢迎使用 "潜进" 版本 {version.ver}'),
Label("选择待学习或待修改的项目:", classes="title-label"), Label(
ListView(id="repo-list", classes="repo-list-view"), f"当前 UNIX 日时间戳: {timer.get_daystamp()}"
Label(f'"潜进" 启发式辅助记忆调度器 | 版本 {version.ver} '), ),
) Label(f"应用时区修正: UTC+{config_var.get()['timezone_offset'] / 3600}"),
Label(f"全局算法设置: {config_var.get()['algorithm']['default']}"),
classes="column infview",
),
Vertical(
Label(f"已加载 {len(self.repostat)} 个单元集", classes='dataview'),
Label(f"共计 {reduce(lambda x, y: x + y, map(lambda x: x.get('unit_sum'), self.repostat.values()))} 个单元", classes='dataview'),
Label(f"已激活 {reduce(lambda x, y: x + y, map(lambda x: x.get('activated_sum'), self.repostat.values()))} 个单元", classes='dataview'),
Label(f"终端尺寸: {os.get_terminal_size()[0]}x{os.get_terminal_size()[1]}"),
classes="column dataview",
),
id="dashboardtop"
)
yield ListView(id="repo-list", classes="repo-list-view")
yield Label(f'"潜进" 启发式辅助记忆调度器 版本 {version.ver} {version.stage.capitalize()}')
yield Footer() yield Footer()
def _load_data(self): def _load_data(self):
self.repo_dirs = Repo.probe_vaild_repos_in_dir( self.repo_dirs = Repo.probe_valid_repos_in_dir(
Path(config_var.get()["paths"]["data"]) / "repo" Path(config_var.get()["paths"]["data"]) / "repo"
) )
for repo_dir in self.repo_dirs: for repo_dir in self.repo_dirs:
@@ -65,7 +91,6 @@ class DashboardScreen(Screen):
unit_sum = len(repo) unit_sum = len(repo)
activated_sum = 0 activated_sum = 0
nextdate = 0x3F3F3F3F nextdate = 0x3F3F3F3F
is_unfinished = unit_sum > activated_sum
for i in repo.ident_index: for i in repo.ident_index:
nucleon = pt.Nucleon.create_on_nucleonic_data( nucleon = pt.Nucleon.create_on_nucleonic_data(
nucleonic_data=repo.nucleonic_data_lict.get_itemic_unit(i) nucleonic_data=repo.nucleonic_data_lict.get_itemic_unit(i)
@@ -78,10 +103,11 @@ class DashboardScreen(Screen):
if electron.is_due(): if electron.is_due():
is_due = 1 is_due = 1
nextdate = min(nextdate, electron.nextdate()) nextdate = min(nextdate, electron.nextdate())
is_unfinished = unit_sum > activated_sum
if is_unfinished: if is_unfinished:
nextdate = min(nextdate, timer.get_daystamp()) nextdate = min(nextdate, timer.get_daystamp())
need_to_study = is_due or is_unfinished need_to_study = is_due or is_unfinished
prompt = f"{title}\0\n 进度: {activated_sum}/{unit_sum}\n {"需要学习" if need_to_study else "无需操作"}" prompt = f"{title}\0\n 进度: {activated_sum}/{unit_sum} ({round(activated_sum/unit_sum*100)}%)\n {'需要学习' if need_to_study else '无需操作'}"
stat = { stat = {
"is_due": is_due, "is_due": is_due,
"unit_sum": unit_sum, "unit_sum": unit_sum,
@@ -96,6 +122,7 @@ class DashboardScreen(Screen):
self.repostat[dirname] = stat self.repostat[dirname] = stat
self.title2dirname[title] = dirname self.title2dirname[title] = dirname
self.title2repo[title] = repo self.title2repo[title] = repo
self.dirname2repo[dirname] = repo
def on_mount(self) -> None: def on_mount(self) -> None:
"""挂载组件时初始化""" """挂载组件时初始化"""
@@ -123,7 +150,7 @@ class DashboardScreen(Screen):
for repotitle in repotitles: for repotitle in repotitles:
prompt = self.repostat[self.title2dirname[repotitle]]["prompt"] prompt = self.repostat[self.title2dirname[repotitle]]["prompt"]
list_item = ListItem(Label(prompt)) list_item = ListItem(Label(prompt), Button(f"开始学习", flat=True, variant="primary", classes="repo_listitem_btn", id=f"launch_{self.repostat[self.title2dirname[repotitle]]['dirname']}"), classes="repo_listitem")
repo_list_widget.append(list_item) repo_list_widget.append(list_item)
# if not self.stay_enabled[repodir]: # if not self.stay_enabled[repodir]:
@@ -135,7 +162,7 @@ class DashboardScreen(Screen):
return return
selected_label = event.item.query_one(Label) selected_label = event.item.query_one(Label)
label_text = str(selected_label.renderable) label_text = str(selected_label.render())
if "未找到任何仓库" in label_text: if "未找到任何仓库" in label_text:
return return
@@ -154,3 +181,15 @@ class DashboardScreen(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 str(event.button.id).startswith("launch_"): # type: ignore
from .preparation import launch
launch(repo=self.dirname2repo[event.button.id[7:]], app=self.app, scheduled_num=-1) # type: ignore
# TODO: 这样启动的记忆实例的状态机无法绑定到 PreparationScreen 中

View File

@@ -0,0 +1,204 @@
"""收藏夹管理器界面"""
import base64
from pathlib import Path
from typing import List, Optional
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 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:
"""组合界面组件"""
yield Header(show_clock=True)
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()
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()["paths"]["data"]) / "repo"
repo_dir = data_repo / repo_path
if not repo_dir.exists():
logger.warning("仓库目录不存在: %s", repo_dir)
return None
repo = Repo.create_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

View File

@@ -1 +0,0 @@
"""笔记界面"""

View File

@@ -0,0 +1,330 @@
from pathlib import Path
from typing import Optional
from textual.app import ComposeResult
from textual.containers import Container, Horizontal
from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Input, Label, RichLog, Static
from heurams.context import *
from heurams.services.llm_service import ChatSession, get_chat_manager
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class LLMChatScreen(Screen):
"""LLM 聊天屏幕"""
SUB_TITLE = "语言模型集成"
BINDINGS = [
("q", "go_back", "返回"),
("ctrl+s", "save_session", "保存会话"),
("ctrl+l", "load_session", "加载会话"),
("ctrl+n", "new_session", "新建会话"),
("ctrl+c", "clear_history", "清空历史"),
("escape", "focus_input", "聚焦输入"),
]
def __init__(
self,
session_id: Optional[str] = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self.session_id = session_id
self.chat_manager = get_chat_manager()
self.current_session: Optional[ChatSession] = None
self.is_streaming = False
def compose(self) -> ComposeResult:
"""组合界面组件"""
yield Header(show_clock=True)
with Container(id="chat-container"):
# 顶部工具栏
with Horizontal(id="toolbar"):
yield Button("新建会话", id="new-session", variant="primary")
yield Button("保存会话", id="save-session", variant="default")
yield Button("加载会话", id="load-session", variant="default")
yield Button("清空历史", id="clear-history", variant="default")
yield Button("设置系统提示", id="set-system-prompt", variant="default")
yield Static(" | ", classes="separator")
yield Label("当前会话:", classes="label")
yield Static(id="current-session-label", classes="session-label")
# 聊天记录显示区域
yield RichLog(
id="chat-log",
wrap=True,
highlight=True,
markup=True,
classes="chat-log",
)
# 输入区域
with Horizontal(id="input-container"):
yield Input(
id="message-input",
placeholder="输入消息... (按 Ctrl+Enter 发送, Esc 聚焦)",
classes="message-input",
)
yield Button(
"发送", id="send-button", variant="primary", classes="send-button"
)
# 状态栏
yield Static(id="status-bar", classes="status-bar")
yield Footer()
def on_mount(self) -> None:
"""挂载组件时初始化"""
# 获取或创建会话
self.current_session = self.chat_manager.get_session(self.session_id)
if self.current_session is None:
self.notify("无法创建 LLM 会话,请检查配置", severity="error")
return
# 更新会话标签
self.query_one("#current-session-label", Static).update(
f"{self.current_session.session_id}"
)
# 加载历史消息到聊天记录
self._display_history()
# 聚焦输入框
self.query_one("#message-input", Input).focus()
# 检查配置
self._check_config()
def _check_config(self):
"""检查 LLM 配置"""
config = config_var.get()
provider_name = config["services"]["llm"]
provider_config = config["providers"]["llm"][provider_name]
if provider_name == "openai":
if not provider_config.get("key") and not provider_config.get("url"):
self.notify(
"未配置 OpenAI API key 或 URL请在 config.toml 中配置 [providers.llm.openai]",
severity="warning",
)
def _display_history(self):
"""显示当前会话的历史消息"""
if not self.current_session:
return
chat_log = self.query_one("#chat-log", RichLog)
chat_log.clear()
for msg in self.current_session.get_history():
role = msg["role"]
content = msg["content"]
if role == "user":
chat_log.write(f"[bold cyan]你:[/bold cyan] {content}")
elif role == "assistant":
chat_log.write(f"[bold green]AI:[/bold green] {content}")
elif role == "system":
# 系统消息不显示在聊天记录中
pass
def _add_message_to_log(self, role: str, content: str):
"""添加消息到聊天记录显示"""
chat_log = self.query_one("#chat-log", RichLog)
if role == "user":
chat_log.write(f"[bold cyan]你:[/bold cyan] {content}")
elif role == "assistant":
chat_log.write(f"[bold green]AI:[/bold green] {content}")
chat_log.scroll_end()
async def on_input_submitted(self, event: Input.Submitted):
"""处理输入提交"""
if event.input.id == "message-input":
await self._send_message()
async def on_button_pressed(self, event: Button.Pressed):
"""处理按钮点击"""
button_id = event.button.id
if button_id == "send-button":
await self._send_message()
elif button_id == "new-session":
self.action_new_session()
elif button_id == "save-session":
self.action_save_session()
elif button_id == "load-session":
self.action_load_session()
elif button_id == "clear-history":
self.action_clear_history()
elif button_id == "set-system-prompt":
self.action_set_system_prompt()
async def _send_message(self):
"""发送当前输入的消息"""
if not self.current_session or self.is_streaming:
return
input_widget = self.query_one("#message-input", Input)
message = input_widget.value.strip()
if not message:
return
# 清空输入框
input_widget.value = ""
# 显示用户消息
self._add_message_to_log("user", message)
# 禁用输入和按钮
self._set_input_state(disabled=True)
self.is_streaming = True
# 更新状态
self.query_one("#status-bar", Static).update("AI 正在思考...")
try:
# 发送消息并获取响应
response = await self.current_session.send_message(message)
# 显示AI响应
self._add_message_to_log("assistant", response)
except Exception as e:
error_msg = f"请求失败: {str(e)}"
logger.error(error_msg)
self._add_message_to_log("assistant", f"[red]{error_msg}[/red]")
self.notify(error_msg, severity="error")
finally:
# 恢复输入和按钮
self._set_input_state(disabled=False)
self.is_streaming = False
self.query_one("#status-bar", Static).update("就绪")
input_widget.focus()
def _set_input_state(self, disabled: bool):
"""设置输入控件状态"""
self.query_one("#message-input", Input).disabled = disabled
self.query_one("#send-button", Button).disabled = disabled
async def action_save_session(self):
"""保存当前会话到文件"""
if not self.current_session:
self.notify("无当前会话", severity="error")
return
# 默认保存到 data/chat_sessions/ 目录
save_dir = Path(config_var.get()["paths"]["data"]) / "chat_sessions"
save_dir.mkdir(exist_ok=True, parents=True)
file_path = save_dir / f"{self.current_session.session_id}.json"
self.current_session.save_to_file(file_path)
self.notify(f"会话已保存到 {file_path}", severity="information")
async def action_load_session(self):
"""从文件加载会话"""
# 简化实现:加载默认目录下的第一个会话文件
save_dir = Path(config_var.get()["paths"]["data"]) / "chat_sessions"
if not save_dir.exists():
self.notify(f"目录不存在: {save_dir}", severity="error")
return
session_files = list(save_dir.glob("*.json"))
if not session_files:
self.notify("未找到会话文件", severity="error")
return
# 使用第一个文件(在实际应用中可以让用户选择)
file_path = session_files[0]
try:
# 获取 LLM 提供者
provider_name = config_var.get()["services"]["llm"]
provider_config = config_var.get()["providers"]["llm"][provider_name]
from heurams.providers.llm import providers as prov
llm_provider = prov[provider_name](provider_config)
# 加载会话
self.current_session = ChatSession.load_from_file(file_path, llm_provider)
# 更新聊天管理器
self.chat_manager.sessions[self.current_session.session_id] = (
self.current_session
)
# 更新UI
self.query_one("#current-session-label", Static).update(
f"{self.current_session.session_id}"
)
self._display_history()
self.notify(f"已加载会话: {file_path.name}", severity="information")
except Exception as e:
logger.error("加载会话失败: %s", e)
self.notify(f"加载失败: {str(e)}", severity="error")
async def action_new_session(self):
"""创建新会话"""
# 简单实现使用时间戳作为会话ID
import time
new_session_id = f"session_{int(time.time())}"
self.current_session = self.chat_manager.get_session(new_session_id)
# 更新UI
self.query_one("#current-session-label", Static).update(
f"{self.current_session.session_id}"
)
self._display_history()
self.notify(f"已创建新会话: {new_session_id}", severity="information")
self.query_one("#message-input", Input).focus()
async def action_clear_history(self):
"""清空当前会话历史"""
if not self.current_session:
return
self.current_session.clear_history()
self._display_history()
self.notify("历史已清空", severity="information")
async def action_set_system_prompt(self):
"""设置系统提示词"""
if not self.current_session:
return
# 使用输入框获取新提示词
input_widget = self.query_one("#message-input", Input)
current_value = input_widget.value
# 临时修改输入框提示
input_widget.placeholder = "输入系统提示词... (按 Enter 确认, Esc 取消)"
input_widget.value = self.current_session.system_prompt
# 等待用户输入
self.notify("请输入系统提示词,按 Enter 确认", severity="information")
# 实际应用中需要更复杂的交互,这里简化处理
# 用户手动输入后按 Enter 会触发 on_input_submitted
# 这里我们只修改占位符,实际系统提示词设置需要额外界面
def action_focus_input(self):
"""聚焦到输入框"""
self.query_one("#message-input", Input).focus()
def action_go_back(self):
"""返回上级屏幕"""
self.app.pop_screen()

View File

@@ -1,6 +1,8 @@
"""队列式记忆工作界面""" """队列式记忆工作界面"""
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path
from typing import Callable
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Center, ScrollableContainer from textual.containers import Center, ScrollableContainer
@@ -8,10 +10,11 @@ from textual.reactive import reactive
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Button, Footer, Header, Label, Static from textual.widgets import Button, Footer, Header, Label, Static
import heurams.kernel.evaluators as pz
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from heurams.context import config_var from heurams.context import config_var
from heurams.kernel.reactor import * from heurams.kernel.reactor import *
from heurams.services.favorite_service import favorite_manager
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .. import shim from .. import shim
@@ -27,27 +30,34 @@ logger = get_logger(__name__)
class MemScreen(Screen): class MemScreen(Screen):
BINDINGS = [ BINDINGS = [
("q", "pop_screen", "返回"), ("q", "go_back", "返回"),
("p", "prev", "查看上一个"), ("p", "prev", "查看上一个"),
("d", "toggle_dark", ""), ("d", "toggle_dark", ""),
("v", "play_voice", "朗读"), ("v", "play_voice", "朗读"),
("*", "toggle_favorite", "收藏"),
("0,1,2,3", "app.push_screen('about')", ""), ("0,1,2,3", "app.push_screen('about')", ""),
] ]
if config_var.get()["quick_pass"]: if config_var.get()["quick_pass"]:
BINDINGS.append(("k", "quick_pass", "跳过")) BINDINGS.append(("k", "quick_pass", "正确应答"))
BINDINGS.append(("f", "quick_fail", "错误应答"))
rating = reactive(-1) rating = reactive(-1)
def __init__( def __init__(
self, self,
phaser: Phaser, phaser: Phaser,
name = None, save_func: Callable,
id = None, repo=None,
classes = None, name=None,
id=None,
classes=None,
) -> None: ) -> None:
super().__init__(name, id, classes) super().__init__(name, id, classes)
self.phaser = phaser self.phaser = phaser
self.save_func = save_func
self.repo = repo
self.update_state() self.update_state()
self.fission: Fission
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
@@ -59,27 +69,53 @@ class MemScreen(Screen):
def update_state(self): def update_state(self):
"""更新状态机""" """更新状态机"""
self.procession: Procession = self.phaser.current_procession() # type: ignore self.procession: Procession = self.phaser.current_procession() # type: ignore
self.atom: pt.Atom = self.procession.current_atom # type: ignore self.atom: pt.Atom = self.procession.current_atom # type: ignore
def on_mount(self): def on_mount(self):
self.fission = self.procession.get_fission()
self.mount_puzzle() self.mount_puzzle()
self.update_display() self.update_display()
def puzzle_widget(self): def puzzle_widget(self):
try: try:
self.fission = self.procession.get_fission() puzzle = self.fission.get_current_puzzle_inf()
puzzle = self.fission.get_current_puzzle() return shim.puzzle2widget[puzzle["puzzle"]]( # type: ignore
# logger.debug(puzzle_debug) atom=self.atom, alia=puzzle["alia"] # type: ignore
return shim.puzzle2widget[puzzle["puzzle"]]( # type: ignore
atom=self.atom, alia=puzzle["alia"] # type: ignore
) )
except (KeyError, StopIteration, AttributeError) as e: except Exception as e:
logger.debug(f"调度展开出错: {e}") logger.debug(f"调度展开出错: {e}")
return Static(f"无法生成谜题 {e}") return Static(f"无法生成谜题 {e}")
# logger.debug(shim.puzzle2widget[puzzle_debug["puzzle"]])
def _get_progress_text(self): def _get_progress_text(self):
return f"当前进度: {self.procession.process() + 1}/{self.procession.total_length()}" s = f"阶段: {self.procession.phase.name}\n"
# 收藏状态
if self.repo is not None:
fav_status = "已收藏" if self._is_current_atom_favorited() else "未收藏"
s += f"收藏: {fav_status}\n"
if config_var.get().get("debug_topline", 0):
try:
alia = self.fission.get_current_puzzle_inf()["alia"] # type: ignore
s += f"谜题: {alia}\n"
except:
pass
try:
stat = self.phaser.__repr__("simple", "")
s += f"{stat}\n"
except:
pass
try:
stat = self.procession.__repr__("simple", "")
s += f"{stat}\n"
except:
pass
try:
stat = self.fission.__repr__("simple", "")
s += f"{stat}\n"
except Exception as e:
s = str(e)
s += f"进度: {self.procession.process() + 1}/{self.procession.total_length()}"
return s
def update_display(self): def update_display(self):
"""更新进度显示""" """更新进度显示"""
@@ -88,6 +124,9 @@ class MemScreen(Screen):
def mount_puzzle(self): def mount_puzzle(self):
"""挂载当前谜题组件""" """挂载当前谜题组件"""
if self.procession.phase == PhaserState.FINISHED:
self.mount_finished_widget()
return
container = self.query_one("#puzzle-container") container = self.query_one("#puzzle-container")
for i in container.children: for i in container.children:
i.remove() i.remove()
@@ -100,7 +139,9 @@ class MemScreen(Screen):
i.remove() i.remove()
from heurams.interface.widgets.finished import Finished from heurams.interface.widgets.finished import Finished
container.mount(Finished()) if config_var.get().get("persist_to_file", 0):
self.save_func()
container.mount(Finished(is_saved=config_var.get().get("persist_to_file", 0)))
def on_button_pressed(self, event): def on_button_pressed(self, event):
event.stop() event.stop()
@@ -115,41 +156,103 @@ class MemScreen(Screen):
from heurams.services.audio_service import play_by_path from heurams.services.audio_service import play_by_path
from heurams.services.hasher import get_md5 from heurams.services.hasher import get_md5
path = Path(config_var.get()["paths"]['data']) / 'cache' / 'voice' path = Path(config_var.get()["paths"]["data"]) / "cache" / "voice"
path = ( path = path / f"{get_md5(self.atom.registry['nucleon']["tts_text"])}.wav"
path
/ f"{get_md5(self.atom.registry['nucleon']["tts_text"])}.wav"
)
if path.exists(): if path.exists():
play_by_path(path) play_by_path(path)
else: else:
from heurams.services.tts_service import convertor from heurams.services.tts_service import convertor
convertor(
self.atom.registry["nucleon"]["tts_text"], path convertor(self.atom.registry["nucleon"]["tts_text"], path)
)
play_by_path(path) play_by_path(path)
def watch_rating(self, old_rating, new_rating) -> None: def watch_rating(self, old_rating, new_rating) -> None:
self.update_state() # 刷新状态 if new_rating == -1: # 安全值
if self.procession == None: # 已经完成记忆
return return
if new_rating == -1: # 安全值 self.update_state()
if self.procession.phase == PhaserState.FINISHED:
rating = -1
return return
forwards = 1 if new_rating >= 4 else 0 # 准许前进 self.fission.report(new_rating)
self.forward(new_rating)
self.rating = -1 self.rating = -1
logger.debug(f"试图前进: {"允许" if forwards else "禁止"}")
if forwards:
ret = self.procession.forward(1) def forward(self, rating):
if ret == 0: # 若结束了此次队列 self.update_state()
self.update_state() allow_forward = 1 if rating >= 4 else 0
if self.procession == 0: # 若所有队列都结束了 if allow_forward:
logger.debug(f"记忆进程结束") self.fission.forward()
self.mount_finished_widget() if self.fission.state == "retronly":
return self.forward_atom(self.fission.get_quality())
else: self.update_state()
logger.debug(f"建立新队列 {self.procession.phase}") self.mount_puzzle()
self.update_state() self.update_display()
self.mount_puzzle()
else: # 若不通过 def atom_reporter(self, quality):
self.procession.append() 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:
self.procession.append()
self.update_state() # 刷新状态
self.procession.forward(1)
self.update_state() # 刷新状态
self.fission = self.procession.get_fission()
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()["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() self.update_display()

View File

@@ -0,0 +1,94 @@
import webbrowser
from textual.app import ComposeResult
from textual.containers import Grid, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Button, Footer, Header, Label, ListItem, ListView, Static
from heurams.context import *
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"),
("电台", "radio"),
("语言模型集成", "llmchat"),
# ("创建仓库", "repo_creator"),
("缓存管理器", "precache_all"),
("收藏夹管理器", FavoriteManagerScreen),
("配置设置", "config"),
("关于此软件", "about"),
("调试日志", "logviewer"),
# ("同步工具", "synctool"),
# ("仓库编辑器", "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()

View File

@@ -3,7 +3,7 @@
import pathlib import pathlib
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Horizontal, ScrollableContainer from textual.containers import Horizontal, ScrollableContainer, Container
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
@@ -12,7 +12,19 @@ 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 *
cache_dir = pathlib.Path(config_var.get()["paths"]["data"]) / "cache" / 'voice' # 兼容性缓存路径:优先使用 paths.cache否则使用 data/cache
paths = config_var.get()["paths"]
cache_dir = pathlib.Path(paths.get("cache", paths["data"] + "/cache")) / "voice"
def format_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):
"""预缓存音频文件屏幕 """预缓存音频文件屏幕
@@ -25,7 +37,9 @@ class PrecachingScreen(Screen):
""" """
SUB_TITLE = "缓存管理器" SUB_TITLE = "缓存管理器"
BINDINGS = [("q", "go_back", "返回")] BINDINGS = [
("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)
@@ -39,38 +53,86 @@ 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
# 不再需要缓存配置,保留配置读取以兼容
self.cache_stats = {"total_size": 0, "file_count": 0, "human_size": "0 B", "cached_units": 0, "total_units": 0, "cache_rate": 0}
self._update_cache_stats()
def _get_total_units(self) -> int:
"""获取所有仓库的总单元数"""
from heurams.context import config_var
from heurams.kernel.repolib import Repo
repo_path = pathlib.Path(config_var.get()["paths"]["data"]) / "repo"
repo_dirs = Repo.probe_valid_repos_in_dir(repo_path)
repos = map(Repo.create_from_repodir, repo_dirs)
total = 0
for repo in repos:
try:
total += len(repo.ident_index)
except:
continue
return total
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"] = format_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) yield Header(show_clock=True)
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():
if self.nucleons: yield Static(
yield Static(f"目标单元归属: [b]{self.desc}[/b]", classes="target-info") f"缓存率: {self.cache_stats.get('cache_rate', 0):.1f}% (已缓存 {self.cache_stats.get('cached_units', 0)} / {self.cache_stats.get('total_units', 0)} 个单元)",
yield Static(f"单元数量: {len(self.nucleons)}", classes="target-info") classes="cache-usage-text"
else: )
yield Static("目标: 所有单元", classes="target-info") if self.nucleons:
yield Static(f"目标单元归属: [b]{self.desc}[/b]", classes="target-info")
yield Static(id="status", classes="status-info") yield Static(f"单元数量: {len(self.nucleons)}", classes="target-info")
yield Static(id="current_item", classes="current-item")
yield ProgressBar(total=100, show_eta=False, id="progress_bar")
with Horizontal(classes="button-group"):
if not self.is_precaching:
yield Button("开始预缓存", id="start_precache", variant="primary")
else: else:
yield Button("取消预缓存", id="cancel_precache", variant="error") yield Static("目标: 所有单元", classes="target-info")
yield Button("清空缓存", id="clear_cache", variant="warning")
yield Button("返回", id="go_back", variant="default")
yield Static("若您离开此界面, 未完成的缓存进程会自动停止.") yield Static(id="status", classes="status-info")
yield Static('缓存程序支持 "断点续传".') yield Static(id="current_item", classes="current-item")
yield ProgressBar(total=100, show_eta=False, id="progress_bar")
with Horizontal(classes="button-group"):
if not self.is_precaching:
yield Button("开始预缓存", id="start_precache", variant="primary")
else:
yield Button("取消预缓存", id="cancel_precache", variant="error")
yield Button("清空缓存", id="clear_cache", variant="warning")
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 Footer() yield Footer()
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):
"""更新状态显示""" """更新状态显示"""
@@ -85,6 +147,25 @@ 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 from heurams.context import config_var, rootdir, workdir
@@ -150,7 +231,7 @@ class PrecachingScreen(Screen):
from heurams.kernel.repolib import Repo from heurams.kernel.repolib import Repo
repo_path = pathlib.Path(config_var.get()["paths"]["data"]) / "repo" repo_path = pathlib.Path(config_var.get()["paths"]["data"]) / "repo"
repo_dirs = Repo.probe_vaild_repos_in_dir(repo_path) repo_dirs = Repo.probe_valid_repos_in_dir(repo_path)
repos = map(Repo.create_from_repodir, repo_dirs) repos = map(Repo.create_from_repodir, repo_dirs)
# 计算总项目数 # 计算总项目数
@@ -204,16 +285,19 @@ class PrecachingScreen(Screen):
from heurams.context import config_var, rootdir, workdir from heurams.context import config_var, rootdir, workdir
shutil.rmtree( shutil.rmtree(cache_dir, ignore_errors=True)
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()
@@ -221,8 +305,3 @@ 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()

View File

@@ -5,14 +5,14 @@ from textual.containers import ScrollableContainer
from textual.reactive import reactive from textual.reactive import reactive
from textual.screen import Screen from textual.screen import Screen
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Button, Footer, Header, Label, Markdown, Static from textual.widgets import Button, Footer, Header, Label, Markdown, Static, Rule, Sparkline
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 *
from heurams.context import config_var from heurams.context import config_var
from heurams.services.logger import get_logger
from heurams.kernel.repolib import * from heurams.kernel.repolib import *
from heurams.services.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -34,6 +34,7 @@ class PreparationScreen(Screen):
super().__init__(name=None, id=None, classes=None) super().__init__(name=None, id=None, classes=None)
self.repo = repo self.repo = repo
self.repostat = repostat self.repostat = repostat
self.load_data()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
@@ -43,7 +44,7 @@ class PreparationScreen(Screen):
f"仓库路径: {config_var.get()['paths']['data']}/repo/[b]{self.repostat['dirname']}[/b]" f"仓库路径: {config_var.get()['paths']['data']}/repo/[b]{self.repostat['dirname']}[/b]"
) )
yield Label(f"\n单元数量: {len(self.repo)}\n") yield Label(f"\n单元数量: {len(self.repo)}\n")
yield Label(f"单次记忆数量: {self.scheduled_num}", id="schnum_label") yield Label(f"最小记忆分组: {self.scheduled_num}\n", id="schnum_label")
yield Button( yield Button(
"开始记忆", "开始记忆",
@@ -58,10 +59,15 @@ class PreparationScreen(Screen):
classes="precache-button", classes="precache-button",
) )
yield Static(f"\n单元预览:\n") yield Static()
yield Markdown(self._get_full_content().replace("/", ""), classes="full") yield Sparkline(self.spark_line_arr, summary_function=max)
yield Rule()
#yield Static(str(self.spark_line_arr))
yield Static(f"单元状态预览:\n")
for i in self.content.splitlines():
yield Static(i, classes="full")
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):
# logger.debug("响应", old_scheduled_num, "->", new_scheduled_num) # logger.debug("响应", old_scheduled_num, "->", new_scheduled_num)
# try: # try:
@@ -70,14 +76,27 @@ class PreparationScreen(Screen):
# except: # except:
# pass # pass
def _get_full_content(self): def load_data(self):
content = "" content = ""
spark_line_arr = []
for i in self.repo.ident_index: for i in self.repo.ident_index:
n = pt.Nucleon.create_on_nucleonic_data( n = pt.Nucleon.create_on_nucleonic_data(
nucleonic_data=self.repo.nucleonic_data_lict.get_itemic_unit(i) nucleonic_data=self.repo.nucleonic_data_lict.get_itemic_unit(i)
) )
content += f"- {n['content']} \n" e = pt.Electron.create_on_electonic_data(electronic_data=self.repo.electronic_data_lict.get_itemic_unit(i))
return content 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()
@@ -104,33 +123,41 @@ 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":
atoms = list() launch(repo=self.repo, app=self.app, scheduled_num=self.scheduled_num)
for i in self.repo.ident_index:
n = pt.Nucleon.create_on_nucleonic_data(
nucleonic_data=self.repo.nucleonic_data_lict.get_itemic_unit(i)
)
e = pt.Electron.create_on_electonic_data(
electronic_data=self.repo.electronic_data_lict.get_itemic_unit(i)
)
a = pt.Atom(n, e, self.repo.orbitic_data)
atoms.append(a)
atoms_to_provide = list()
left_new = self.scheduled_num
for i in atoms:
i: pt.Atom
if i.registry["electron"].is_activated():
if i.registry["electron"].is_due():
atoms_to_provide.append(i)
else:
left_new -= 1
if left_new >= 0:
atoms_to_provide.append(i)
from .memoqueue import MemScreen
import heurams.kernel.reactor as rt
pheser = rt.Phaser(atoms_to_provide)
memscreen = MemScreen(pheser)
self.app.push_screen(memscreen)
elif event.button.id == "precache_button": elif event.button.id == "precache_button":
self.action_precache() self.action_precache()
def launch(repo, app, scheduled_num):
if scheduled_num == -1:
scheduled_num = config_var.get()["scheduled_num"]
atoms = list()
for i in repo.ident_index:
n = pt.Nucleon.create_on_nucleonic_data(
nucleonic_data=repo.nucleonic_data_lict.get_itemic_unit(i)
)
e = pt.Electron.create_on_electonic_data(
electronic_data=repo.electronic_data_lict.get_itemic_unit(i)
)
a = pt.Atom(n, e, repo.orbitic_data)
atoms.append(a)
atoms_to_provide = list()
left_new = scheduled_num
for i in atoms:
i: pt.Atom
if i.registry["electron"].is_activated():
if i.registry["electron"].is_due():
atoms_to_provide.append(i)
else:
left_new -= 1
if left_new >= 0:
atoms_to_provide.append(i)
import heurams.kernel.reactor as rt
from .memoqueue import MemScreen
pheser = rt.Phaser(atoms_to_provide)
save_func = repo.persist_to_repodir
memscreen = MemScreen(pheser, save_func, repo=repo)
app.push_screen(memscreen)

View File

@@ -1 +1,217 @@
"""用于筛选当日记忆的条目 以音频形式重放"""
""" "前进电台" 界面""" """ "前进电台" 界面"""
import os
from pathlib import Path
from typing import List, Optional
from textual.app import ComposeResult
from textual.containers import Container, 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
from heurams.kernel.repolib import Repo
from heurams.context import config_var
from heurams.services.audio_service import play_by_path
from heurams.services.hasher import get_md5
from heurams.services.logger import get_logger
from heurams.services.tts_service import convertor
logger = get_logger(__name__)
class RadioScreen(Screen):
SUB_TITLE = "电台"
BINDINGS = [
("q", "go_back", "返回"),
("space", "toggle_play", "播放/暂停"),
]
# 当前播放的原子索引
current_index = reactive(0)
# 播放状态: 'stopped', 'playing', 'paused'
play_state = reactive("stopped")
def __init__(
self,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self._organizer()
def _organizer(self):
repodirs = Repo.probe_valid_repos_in_dir(Path(config_var.get()['paths']['data']) / 'repo')
repos = list(map(lambda repodir: Repo.create_from_repodir(repodir), repodirs))
for repo in repos:
last_modify = 0.0
for i in repo.ident_index:
e = pt.Electron.create_on_electonic_data(
electronic_data=repo.electronic_data_lict.get_itemic_unit(i)
)
last_modify = max(last_modify, e.las())
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Container(id="main"):
yield Label("[b]前进电台[/b]", classes="title")
yield Static(f"{len(self.atoms)} 条当日记忆", id="status")
with Container(id="controls"):
yield Button("播放", id="play", variant="success")
yield Button("暂停", id="pause", variant="primary")
yield Button("上一首", id="prev", variant="default")
yield Button("下一首", id="next", variant="default")
yield Button("停止", id="stop", variant="error")
yield ScrollableContainer(id="playlist")
yield Footer()
def on_mount(self) -> None:
"""挂载后更新播放列表显示"""
self._update_playlist()
def _filter_due_atoms(self) -> List[pt.Atom]:
"""筛选当日需要复习的原子(已激活且到期)"""
atoms = []
for ident in self.repo.ident_index:
n = pt.Nucleon.create_on_nucleonic_data(
nucleonic_data=self.repo.nucleonic_data_lict.get_itemic_unit(ident)
)
e = pt.Electron.create_on_electonic_data(
electronic_data=self.repo.electronic_data_lict.get_itemic_unit(ident)
)
a = pt.Atom(n, e, self.repo.orbitic_data)
# 仅选择已激活且到期的原子
if (
a.registry["electron"].is_activated()
and a.registry["electron"].is_due()
):
atoms.append(a)
return atoms
def _update_playlist(self) -> None:
"""更新播放列表显示"""
container = self.query_one("#playlist")
container.remove_children()
for idx, atom in enumerate(self.atoms):
content = atom.registry["nucleon"].get("content", "无内容")
prefix = "" if idx == self.current_index else " "
widget = Static(f"{prefix}{idx+1}. {content[:50]}...")
widget.set_class(idx == self.current_index, "current")
container.mount(widget)
def _get_audio_path(self, atom: pt.Atom) -> Path:
"""返回音频文件路径,若不存在则生成"""
tts_text = atom.registry["nucleon"].get("tts_text", "")
if not tts_text:
tts_text = atom.registry["nucleon"].get("content", "")
voice_dir = Path(config_var.get()["paths"]["data"]) / "cache" / "voice"
voice_dir.mkdir(parents=True, exist_ok=True)
path = voice_dir / f"{get_md5(tts_text)}.wav"
if not path.exists():
convertor(tts_text, path)
return path
async def _play_atom(self, idx: int) -> None:
"""播放指定索引的原子(异步)"""
if idx < 0 or idx >= len(self.atoms):
return
atom = self.atoms[idx]
try:
path = self._get_audio_path(atom)
self._current_path = path
# 在后台线程中播放避免阻塞UI
await self.run_worker(
lambda: play_by_path(path), exclusive=True, thread=True
)
except Exception as e:
logger.error("播放失败: %s", e)
def _stop_playback(self) -> None:
"""停止当前播放"""
if self._play_task and not self._play_task.done():
self._play_task.cancel()
self._play_task = None
self._current_path = None
self.play_state = "stopped"
async def _play_current(self) -> None:
"""播放当前索引的原子"""
self._stop_playback()
self.play_state = "playing"
self._play_task = asyncio.create_task(self._play_atom(self.current_index))
try:
await self._play_task
except asyncio.CancelledError:
pass
finally:
if self.play_state == "playing":
self.play_state = "stopped"
# 按钮事件处理
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id
if button_id == "play":
self.action_toggle_play()
elif button_id == "pause":
self.action_pause()
elif button_id == "prev":
self.action_prev()
elif button_id == "next":
self.action_next()
elif button_id == "stop":
self.action_stop()
# 键盘动作
def action_toggle_play(self) -> None:
if self.play_state == "playing":
self.action_pause()
else:
self.action_play()
def action_play(self) -> None:
if self.play_state != "playing":
if self.play_state == "paused":
# 恢复播放(目前暂停功能简单实现为停止)
self.play_state = "playing"
else:
asyncio.create_task(self._play_current())
def action_pause(self) -> None:
if self.play_state == "playing":
self._stop_playback()
self.play_state = "paused"
def action_stop(self) -> None:
self._stop_playback()
self.play_state = "stopped"
def action_next(self) -> None:
if self.current_index < len(self.atoms) - 1:
self.current_index += 1
self._update_playlist()
if self.play_state == "playing":
asyncio.create_task(self._play_current())
def action_prev(self) -> None:
if self.current_index > 0:
self.current_index -= 1
self._update_playlist()
if self.play_state == "playing":
asyncio.create_task(self._play_current())
def action_go_back(self) -> None:
self._stop_playback()
self.app.pop_screen()
# 响应式更新
def watch_current_index(self, old: int, new: int) -> None:
self._update_playlist()
def watch_play_state(self, old: str, new: str) -> None:
# 更新按钮状态(可在此添加样式变化)
pass

View File

@@ -24,7 +24,7 @@ class RepoCreatorScreen(Screen):
from heurams.context import config_var from heurams.context import config_var
template_dir = Path(config_var.get()["paths"]["template_dir"]) template_dir = Path(config_var.get()["paths"]["data"]) / "templates"
templates = list() templates = list()
for i in template_dir.iterdir(): for i in template_dir.iterdir():
if i.name.endswith(".toml"): if i.name.endswith(".toml"):

View File

@@ -0,0 +1,267 @@
"""仓库编辑器, 使用TextArea控件等实现仓库配置编辑"""
import json
from pathlib import Path
from typing import Optional
import toml
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import (
Button,
Footer,
Header,
Label,
ListItem,
ListView,
Static,
TextArea,
)
from heurams.context import config_var
from heurams.kernel.repolib import Repo
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class RepoEditorScreen(Screen):
"""仓库编辑器屏幕"""
SUB_TITLE = "仓库编辑器"
BINDINGS = [
("q", "go_back", "返回"),
("s", "save_file", "保存"),
("r", "reload_file", "重载"),
("d", "toggle_dark", ""),
]
# 当前选择的仓库路径
selected_repo_path: reactive[Optional[Path]] = reactive(None)
# 当前选择的文件名
selected_filename: reactive[Optional[str]] = reactive(None)
# 文件内容
file_content: reactive[str] = reactive("")
def __init__(
self,
repo: Optional[Repo] = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self.repo = repo
self.repo_dir: Optional[Path] = None
self.file_list = []
if repo is not None and repo.source is not None:
self.repo_dir = repo.source
self._load_file_list()
# selected_repo_path 将在 on_mount 中设置避免触发watch时组件未就绪
def _load_file_list(self) -> None:
"""加载仓库目录下的文件列表"""
if self.repo_dir is None:
return
self.file_list = []
for fname in Repo.file_mapping.values():
fpath = self.repo_dir / fname
if fpath.exists():
self.file_list.append(fname)
# 也可能存在其他文件,但暂时只支持标准文件
self.file_list.sort()
def compose(self) -> ComposeResult:
"""组合界面组件"""
yield Header(show_clock=True)
with Container(id="main_container"):
with Horizontal(id="top_panel"):
# 左侧: 仓库选择
with Vertical(id="repo_selector", classes="panel"):
yield Label("仓库列表", classes="panel-title")
yield ListView(
*[
ListItem(Label(repo_dir.name))
for repo_dir in self._get_repo_dirs()
],
id="repo_list",
classes="list-view",
)
# 中间: 文件列表
with Vertical(id="file_selector", classes="panel"):
yield Label("文件列表", classes="panel-title")
yield ListView(
*[ListItem(Label(fname)) for fname in self.file_list],
id="file_list",
classes="list-view",
)
# 右侧: 编辑区域
with Vertical(id="editor_panel", classes="panel"):
yield Label("编辑文件", classes="panel-title")
yield TextArea(
id="text_editor",
language="plaintext",
classes="text-editor",
)
with Horizontal(id="button_bar"):
yield Button("保存", id="save_button", variant="primary")
yield Button("重载", id="reload_button", variant="default")
yield Button("返回", id="back_button", variant="error")
yield Footer()
def _get_repo_dirs(self) -> list[Path]:
"""获取data/repo/下所有有效仓库目录"""
repo_root = Path(config_var.get()["paths"]["data"]) / "repo"
repo_dirs = []
if repo_root.exists():
for entry in repo_root.iterdir():
if entry.is_dir():
# 检查是否存在 manifest.toml
if (entry / "manifest.toml").exists():
repo_dirs.append(entry)
return repo_dirs
def on_mount(self) -> None:
"""挂载组件时初始化"""
# 如果已有仓库,设置 selected_repo_path 以触发watch此时组件已就绪
if self.repo_dir is not None:
self.selected_repo_path = self.repo_dir
# 焦点放在仓库列表
self.query_one("#repo_list", ListView).focus()
def watch_selected_repo_path(
self, old_path: Optional[Path], new_path: Optional[Path]
) -> None:
"""当选择的仓库路径变化时,加载文件列表"""
if new_path is None:
self.file_list = []
self.selected_filename = None
self.file_content = ""
return
self.repo_dir = new_path
self._load_file_list()
# 如果组件已挂载更新UI
if self.is_mounted:
file_list_view = self.query_one("#file_list", ListView)
file_list_view.clear()
for fname in self.file_list:
file_list_view.append(ListItem(Label(fname)))
# 清空编辑器
self.query_one("#text_editor", TextArea).text = ""
self.selected_filename = None
def watch_selected_filename(
self, old_name: Optional[str], new_name: Optional[str]
) -> None:
"""当选择的文件名变化时,加载文件内容"""
if new_name is None or self.repo_dir is None:
self.file_content = ""
return
file_path = self.repo_dir / new_name
if not file_path.exists():
self.notify(f"文件不存在: {new_name}", severity="error")
return
try:
content = file_path.read_text(encoding="utf-8")
self.file_content = content
# 如果组件已挂载,更新编辑器
if self.is_mounted:
editor = self.query_one("#text_editor", TextArea)
editor.text = content
# 根据文件后缀设置语言
if new_name.endswith(".toml"):
editor.language = "toml"
elif new_name.endswith(".json"):
editor.language = "json"
else:
editor.language = "plaintext"
except Exception as e:
logger.error(f"读取文件失败: {e}")
self.notify(f"读取文件失败: {e}", severity="error")
def watch_file_content(self, old_content: str, new_content: str) -> None:
"""当文件内容变化时更新编辑器(仅当外部改变时)"""
# 目前不需要做任何事情,因为编辑器内容已绑定
pass
def on_list_view_selected(self, event) -> None:
"""处理列表项选择事件"""
if not isinstance(event.item, ListItem):
return
list_id = event.list_view.id
selected_label = event.item.query_one(Label)
selected_text = str(selected_label.render())
if list_id == "repo_list":
# 用户选择了仓库
repo_root = Path(config_var.get()["paths"]["data"]) / "repo"
selected_dir = repo_root / selected_text
if selected_dir.exists():
self.selected_repo_path = selected_dir
elif list_id == "file_list":
# 用户选择了文件
if self.repo_dir is None:
self.notify("请先选择仓库", severity="warning")
return
self.selected_filename = selected_text
def on_button_pressed(self, event) -> None:
"""处理按钮点击事件"""
event.stop()
if event.button.id == "save_button":
self.action_save_file()
elif event.button.id == "reload_button":
self.action_reload_file()
elif event.button.id == "back_button":
self.action_go_back()
def action_save_file(self) -> None:
"""保存当前编辑的文件"""
if self.repo_dir is None or self.selected_filename is None:
self.notify("未选择仓库或文件", severity="warning")
return
file_path = self.repo_dir / self.selected_filename
editor = self.query_one("#text_editor", TextArea)
new_content = editor.text
# 验证格式
try:
if self.selected_filename.endswith(".toml"):
toml.loads(new_content) # 验证TOML
elif self.selected_filename.endswith(".json"):
json.loads(new_content) # 验证JSON
except Exception as e:
self.notify(f"格式错误: {e}", severity="error")
return
# 写入文件
try:
file_path.write_text(new_content, encoding="utf-8")
self.notify("保存成功", severity="information")
except Exception as e:
logger.error(f"保存文件失败: {e}")
self.notify(f"保存文件失败: {e}", severity="error")
def action_reload_file(self) -> None:
"""重新加载当前文件(放弃修改)"""
if self.repo_dir is None or self.selected_filename is None:
self.notify("未选择仓库或文件", severity="warning")
return
file_path = self.repo_dir / self.selected_filename
try:
content = file_path.read_text(encoding="utf-8")
editor = self.query_one("#text_editor", TextArea)
editor.text = content
self.notify("已重载", severity="information")
except Exception as e:
logger.error(f"重载文件失败: {e}")
self.notify(f"重载文件失败: {e}", severity="error")
def action_go_back(self) -> None:
"""返回上一屏幕"""
self.app.pop_screen()
def action_toggle_dark(self) -> None:
"""切换暗色模式"""
self.app.dark = not self.app.dark

View File

@@ -1,10 +1,11 @@
"""Kernel 操作辅助函数库""" """Kernel 操作辅助函数库"""
import heurams.interface.widgets as pzw import heurams.interface.widgets as pzw
import heurams.kernel.evaluators as pz import heurams.kernel.puzzles as pz
puzzle2widget = { puzzle2widget = {
pz.RecognitionPuzzle: pzw.Recognition, pz.RecognitionPuzzle: pzw.Recognition,
pz.ClozePuzzle: pzw.ClozePuzzle, pz.ClozePuzzle: pzw.ClozePuzzle,
pz.MCQPuzzle: pzw.MCQPuzzle, pz.MCQPuzzle: pzw.MCQPuzzle,
pz.BaseEvaluator: pzw.BasePuzzleWidget, pz.BasePuzzle: pzw.BasePuzzleWidget,
} }

View File

@@ -5,10 +5,11 @@ from typing import TypedDict
from textual.containers import Container from textual.containers import Container
from textual.message import Message from textual.message import Message
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Button, Label from textual.widgets import Button, Label, Markdown
import heurams.kernel.evaluators as pz
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
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
@@ -53,7 +54,7 @@ class ClozePuzzle(BasePuzzleWidget):
self.hashmap = dict() self.hashmap = dict()
def _load(self): def _load(self):
setting = self.atom.registry["orbital"]["puzzles"][self.alia] setting = self.atom.registry["nucleon"]["puzzles"][self.alia]
self.puzzle = pz.ClozePuzzle( self.puzzle = pz.ClozePuzzle(
text=setting["text"], text=setting["text"],
delimiter=setting["delimiter"], delimiter=setting["delimiter"],
@@ -65,7 +66,7 @@ class ClozePuzzle(BasePuzzleWidget):
def compose(self): def compose(self):
yield Label(self.puzzle.wording, id="sentence") yield Label(self.puzzle.wording, id="sentence")
yield Label(f"当前输入: {self.inputlist}", id="inputpreview") yield Markdown(f"> {self.listprint(self.inputlist)}", id="inputpreview")
# 渲染当前问题的选项 # 渲染当前问题的选项
with Container(id="btn-container"): with Container(id="btn-container"):
for i in self.ans: for i in self.ans:
@@ -76,9 +77,18 @@ class ClozePuzzle(BasePuzzleWidget):
yield Button("退格", id="delete") yield Button("退格", id="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.inputlist}") # type: ignore preview.update(f"> {self.listprint(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

View File

@@ -7,24 +7,27 @@ 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("返回上一级", id="back-to-menu")
def on_button_pressed(self, event): def on_button_pressed(self, event):

View File

@@ -5,8 +5,8 @@ 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
import heurams.kernel.evaluators as pz
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
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
@@ -54,27 +54,25 @@ class MCQPuzzle(BasePuzzleWidget):
self._load() self._load()
def _load(self): def _load(self):
cfg = self.atom.registry["orbital"]["puzzles"][self.alia] cfg = self.atom.registry["nucleon"]["puzzles"][self.alia]
if cfg['mapping'] == {}:
self.screen.rating = 5 # type: ignore
self.puzzle = pz.MCQPuzzle( 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"].metadata["orbital"]["puzzles"][ setting: Setting = self.atom.registry["nucleon"]["puzzles"][self.alia]
self.alia
]
logger.debug(f"Puzzle Setting: {setting}")
logger.debug(f"WIRED INDEX: {len(self.inputlist)}")
if len(self.inputlist) > len(self.puzzle.options): if len(self.inputlist) > len(self.puzzle.options):
logger.debug("ERR IDX") logger.debug("ERR IDX")
logger.debug(self.inputlist) logger.debug(self.inputlist)
logger.debug(self.puzzle.options) logger.debug(self.puzzle.options)
else: 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")
# 渲染当前问题的选项 # 渲染当前问题的选项
with Container(id="btn-container"): with Container(id="btn-container"):
@@ -154,7 +152,7 @@ class MCQPuzzle(BasePuzzleWidget):
for option in current_options: for option in current_options:
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] = option self.hashmap[button_id[7:]] = option
new_button = Button(option, id=button_id) new_button = Button(option, id=button_id)
container.mount(new_button) container.mount(new_button)

View File

@@ -67,9 +67,7 @@ class Recognition(BasePuzzleWidget):
f";{delim}": ";", f";{delim}": ";",
f":{delim}": ":", f":{delim}": ":",
} }
nucleon = self.atom.registry["nucleon"]
metadata = self.atom.registry["nucleon"]
primary = cfg["primary"] primary = cfg["primary"]
with Center(): with Center():
@@ -90,12 +88,12 @@ 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"### {metadata['annotation'][item]}: {j}") yield Markdown(f"### 笔记: {j}") #TODO ANNOTATION
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)
@@ -107,13 +105,3 @@ class Recognition(BasePuzzleWidget):
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

View File

@@ -1,11 +1,7 @@
from heurams.services.logger import get_logger
from .base import BaseAlgorithm from .base import BaseAlgorithm
from .sm2 import SM2Algorithm from .sm2 import SM2Algorithm
from .sm15m import SM15MAlgorithm from .sm15m import SM15MAlgorithm
logger = get_logger(__name__)
__all__ = [ __all__ = [
"SM2Algorithm", "SM2Algorithm",
"BaseAlgorithm", "BaseAlgorithm",
@@ -17,5 +13,3 @@ algorithms = {
"SM-15M": SM15MAlgorithm, "SM-15M": SM15MAlgorithm,
"Base": BaseAlgorithm, "Base": BaseAlgorithm,
} }
logger.debug("算法模块初始化完成, 注册的算法: %s", list(algorithms.keys()))

View File

@@ -0,0 +1,5 @@
from .evalizor import Evalizer
from .lict import Lict
from .refvar import RefVar
__all__ = ["Evalizer", "Lict", "RefVar"]

View File

@@ -1,63 +0,0 @@
"""
Evaluator 模块 - 生成评估模块
提供多种类型的辅助评估生成器, 支持从字符串、字典等数据源导入题目
"""
from heurams.services.logger import get_logger
logger = get_logger(__name__)
from .base import BaseEvaluator
from .cloze import ClozePuzzle
from .mcq import MCQPuzzle
from .recognition import RecognitionPuzzle
__all__ = [
"BaseEvaluator",
"ClozePuzzle",
"MCQPuzzle",
"RecognitionPuzzle",
]
puzzles = {
"mcq": MCQPuzzle,
"cloze": ClozePuzzle,
"recognition": RecognitionPuzzle,
"base": BaseEvaluator,
}
@staticmethod
def create_by_dict(config_dict: dict) -> BaseEvaluator:
"""
根据配置字典创建谜题
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}")

View File

@@ -1,4 +1,21 @@
from .atom import Atom from .atom import Atom
from .electron import Electron from .electron import Electron
from .nucleon import Nucleon from .nucleon import Nucleon
#from .orbital import Orbital from .placeholders import (
AtomPlaceholder,
ElectronPlaceholder,
NucleonPlaceholder,
orbital_placeholder,
)
# from .orbital import Orbital
__all__ = [
"Atom",
"Electron",
"Nucleon",
"AtomPlaceholder",
"NucleonPlaceholder",
"ElectronPlaceholder",
"orbital_placeholder",
]

View File

@@ -1,6 +1,5 @@
from typing import TypedDict from typing import TypedDict
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .electron import Electron from .electron import Electron

View File

@@ -53,10 +53,19 @@ class Electron:
result = self.algo.is_due(self.algodata) result = self.algo.is_due(self.algodata)
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"]
return result return result
def last_modify(self):
result = self.algodata[self.algo.algo_name]["last_modify"]
return result
def get_rating(self): def get_rating(self):
try: try:
result = self.algo.get_rating(self.algodata) result = self.algo.get_rating(self.algodata)
@@ -68,6 +77,10 @@ class Electron:
result = self.algo.nextdate(self.algodata) result = self.algo.nextdate(self.algodata)
return result return result
def lastdate(self) -> int:
result = self.algodata[self.algo.algo_name]["lastdate"]
return result
def revisor(self, quality: int = 5, is_new_activation: bool = False): def revisor(self, quality: int = 5, is_new_activation: bool = False):
"""算法迭代决策机制实现 """算法迭代决策机制实现

View File

@@ -1,9 +1,9 @@
from copy import deepcopy from copy import deepcopy
from logging import config from logging import config
from heurams.services.logger import get_logger
from heurams.utils.evalizor import Evalizer
from heurams.context import config_var from heurams.context import config_var
from heurams.services.logger import get_logger
from heurams.kernel.auxiliary.evalizor import Evalizer
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -13,11 +13,28 @@ class Nucleon:
def __init__(self, ident, payload, common): def __init__(self, ident, payload, common):
self.ident = ident self.ident = ident
env = {"payload": payload, try:
"default": config_var.get()['puzzles'], data_safe = deepcopy((payload | common))
"nucleon": (payload | common)} data_puz = deepcopy(data_safe['puzzles'])
self.evalizer = Evalizer(environment=env) data_safe['puzzles'] = {}
self.data: dict = self.evalizer(deepcopy((payload | common))) # type: ignore env = {
"payload": data_safe,
"default": config_var.get()["puzzles"],
"nucleon": data_safe,
}
self.evalizer = Evalizer(environment=env)
data_safe = self.evalizer(deepcopy(data_safe))
env = {
"payload": data_safe,
"default": config_var.get()["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): if isinstance(key, str):

View File

@@ -9,7 +9,7 @@ orbital, 即轨道, 是定义队列式复习阶段流程的数据结构, 其实
orbital_example = { orbital_example = {
"schedule": [列表 存储阶段(phases)名称] "schedule": [列表 存储阶段(phases)名称]
"phases":{ "phases":{
阶段名称 = [["谜题(puzzle 现称 evaluator 评估器)名称", "概率系数 可大于1(整数部分为重复次数) 注意使用字符串包裹(toml 规范)"], ...], 阶段名称 = [["谜题(puzzle 现称 Puzzles 评估器)名称", "概率系数 可大于1(整数部分为重复次数) 注意使用字符串包裹(toml 规范)"], ...],
... ...
} }
} }

View File

@@ -0,0 +1,42 @@
from heurams.kernel.particles import orbital
from .atom import Atom
from .electron import Electron
from .nucleon import Nucleon
orbital_placeholder = {
"schedule": ["quick_review", "recognition", "final_review"],
"phases": {
"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
)

View File

@@ -0,0 +1,26 @@
"""
Puzzles 模块 - 生成评估模块
提供多种类型的辅助评估生成器, 支持从字符串、字典等数据源导入题目
"""
from heurams.services.logger import get_logger
from .base import BasePuzzle
from .cloze import ClozePuzzle
from .mcq import MCQPuzzle
from .recognition import RecognitionPuzzle
__all__ = [
"BasePuzzle",
"ClozePuzzle",
"MCQPuzzle",
"RecognitionPuzzle",
]
puzzles = {
"mcq": MCQPuzzle,
"cloze": ClozePuzzle,
"recognition": RecognitionPuzzle,
"base": BasePuzzle,
}

View File

@@ -4,7 +4,7 @@ from heurams.services.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
class BaseEvaluator: class BasePuzzle:
"""谜题基类""" """谜题基类"""
def refresh(self): def refresh(self):

View File

@@ -2,12 +2,12 @@ import random
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .base import BaseEvaluator from .base import BasePuzzle
logger = get_logger(__name__) logger = get_logger(__name__)
class ClozePuzzle(BaseEvaluator): class ClozePuzzle(BasePuzzle):
"""填空题谜题生成器 """填空题谜题生成器
Args: Args:

View File

@@ -2,11 +2,11 @@ import random
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .base import BaseEvaluator from .base import BasePuzzle
logger = get_logger(__name__) logger = get_logger(__name__)
class GuessEvaluator(BaseEvaluator): class GuessPuzzle(BasePuzzle):
def __init__(self): def __init__(self):
super().__init__() super().__init__()

View File

@@ -4,12 +4,12 @@ from typing import Dict, List, Optional, Union
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .base import BaseEvaluator from .base import BasePuzzle
logger = get_logger(__name__) logger = get_logger(__name__)
class MCQPuzzle(BaseEvaluator): class MCQPuzzle(BasePuzzle):
"""选择题谜题生成器 """选择题谜题生成器
该类用于生成和管理选择题谜题, 支持多个题目同时生成, 该类用于生成和管理选择题谜题, 支持多个题目同时生成,

View File

@@ -3,12 +3,12 @@ import random
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .base import BaseEvaluator from .base import BasePuzzle
logger = get_logger(__name__) logger = get_logger(__name__)
class RecognitionPuzzle(BaseEvaluator): class RecognitionPuzzle(BasePuzzle):
"""识别占位符""" """识别占位符"""
def __init__(self) -> None: def __init__(self) -> None:

View File

@@ -1 +1,123 @@
# Reactor - 记忆程状态机模块 # Reactor - 记忆程状态机模块
Reactor 是 HeurAMS 的记忆流程状态机模块, 和界面 (interface) 的实现是解耦的, 以便后期与其他框架的适配.
得益于 Pickle, 状态机模块支持快照!
## Phaser - 全局阶段控制器
在一次队列记忆流程中, Phaser 代表记忆流程本身.
### 属性
#### 状态属性
其有状态属性:
- unsure - 用于初始化
- *quick_review - 复习逾期的单元
- *recognition - 辨识新单元
- *final_review - 复习所有逾期的和新辨认的单元
- finished - 表示完成
> 逾期的: 指 SM-2 算法间隔显示应该复习的单元
带 * 的属性表示实际的记忆阶段, 由 repo 中 schedule.toml 中 schedule 列表显式声明, 运行过程中可以选择性执行, "空的" Procession 会被直接跳过.
在初始化 Procession 时, 每个 Procession 被赋予一个不重复的状态属性 作为"阶段状态"属性, 以此标识 Procession 的阶段属性, 因为每个 Procession 管理一个阶段下的复习进程.
你可以用 state 属性获取 Phaser 的当前状态.
#### Procession 属性
储存一个顺序列表, 保存所有构造的 Procession.
顺序与 repo 中 schedule.toml 中 schedule 列表中的顺序完全相同
### 初始化
Phaser 接受一个存储 Atom 对象的列表, 作为组织记忆流程的材料
在内部, 根据是否激活将其分为 new_atoms 与 old_atoms.
因此, 如果你传入的列表中有算法上"无所事事"的 Atom, 流程会对其进行"加强复习"
由此创建 Procession.
### 直接输出呈现形式
Phaser 的 __repr__ 定义了此对象"官方的显示"用作直观的调试.
其以 ascii 表格形式输出, 格式也符合 markdown 表格规范, 你可以直接复制到 markdown.
示例:
```text
| Type | State | Processions | Current Procession |
|:-------|:--------|:-----------------------|:---------------------|
| Phaser | unsure | ['新记忆', '总体复习'] | 新记忆 |
```
| Type | State | Processions | Current Procession |
|:-------|:--------|:-----------------------|:---------------------|
| Phaser | 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 列表中的索引
- phase: "阶段属性"
> 注意区分 "Phaser" 和 "Phase", 其中 "Phase" 表示 "Phaser State".
- name_: 阶段的命名
- state: 当前状态属性
### 初始化
接受一个 atoms 列表与 phase_state (PhaserState Enum 类型)对象
### 直接输出呈现形式
同 Phaser, 但显示数据有所不同
与 Phaser 不同, Procession 显示队列会对过长的 atom.ident 进行缩略(末尾 `>` 符号)
```text
| Type | Name | State | Progress | Queue | Current Atom |
|:-----------|:-------|:--------|:-----------|:-----------------------|:------------------------------|
| Procession | 新记忆 | active | 1 / 2 | ['秦孝公>', '君臣固>'] | 秦孝公据崤函之固, 拥雍州之地, |
```
| Type | Name | State | Progress | Queue | 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_fission(self)
获取当前原子的 Fission 对象, 用于单原子调度展开
## Fission - 单原子调度控制器
### 属性
#### 状态属性
- exammode: 测试模式(默认)
- retronly: 仅回顾模式
#### 其他属性
- cursor
- atom
- current_puzzle
- orbital_schedule
- orbital_puzzles
- puzzles
### 初始化
接受 atom 对象和 phase 参数
### 方法
#### get_puzzles(self)

View File

@@ -5,8 +5,4 @@ from .phaser import Phaser
from .procession import Procession from .procession import Procession
from .states import PhaserState, ProcessionState from .states import PhaserState, ProcessionState
logger = get_logger(__name__)
__all__ = ["PhaserState", "ProcessionState", "Procession", "Fission", "Phaser"] __all__ = ["PhaserState", "ProcessionState", "Procession", "Fission", "Phaser"]
logger.debug("反应堆模块已加载")

View File

@@ -1,70 +1,126 @@
import random import random
from functools import reduce
from tabulate import tabulate as tabu
from transitions import Machine
import heurams.kernel.evaluators as puz
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
import heurams.kernel.puzzles as puz
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .states import PhaserState from .states import FissionState, PhaserState
logger = get_logger(__name__)
class Fission: class Fission(Machine):
"""单原子调度展开器""" """单原子调度展开器"""
def __init__(self, atom: pt.Atom, phase_state=PhaserState.RECOGNITION): def __init__(self, atom: pt.Atom, phase=PhaserState.RECOGNITION):
self.phase = phase
self.cursor = 0 self.cursor = 0
self.logger = get_logger(__name__)
self.atom = atom self.atom = atom
self.current_puzzle_inf: dict
# phase 为 PhaserState 枚举实例, 需要获取其value
phase_value = phase.value
states = [
{"name": FissionState.EXAMMODE.value},
{"name": FissionState.RETRONLY.value},
]
# NOTE: phase 为 PhaserState 枚举实例需要获取其value transitions = [
phase_value = ( {
phase_state.value if isinstance(phase_state, PhaserState) else phase_state "trigger": "finish",
) "source": FissionState.EXAMMODE.value,
"dest": FissionState.RETRONLY.value,
},
]
if phase == PhaserState.FINISHED:
Machine.__init__(
self,
states=states,
transitions=transitions,
initial=FissionState.EXAMMODE.value,
)
return
orbital_schedule = atom.registry["orbital"]["phases"][phase_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}")
self.orbital_schedule = atom.registry['orbital']["phases"][phase_value] # type: ignore puzzle = puz.puzzles[orbital_puzzles[item]["__origin__"]]
self.orbital_puzzles = atom.registry["nucleon"]["puzzles"]
self.puzzles = list()
for item, possibility in self.orbital_schedule: # type: ignore
self.logger.debug(f"开始处理: {item}")
if not isinstance(possibility, float): if not isinstance(possibility, float):
possibility = float(possibility) possibility = float(possibility)
while possibility > 1: while possibility > 1:
self.puzzles.append( self.puzzles_inf.append(
{ {
"puzzle": puz.puzzles[self.orbital_puzzles[item]["__origin__"]], "puzzle": puzzle,
"alia": item, "alia": item,
"finished": 0,
} }
) )
possibility -= 1 possibility -= 1
if random.random() <= possibility: if random.random() <= possibility:
self.puzzles.append( self.puzzles_inf.append(
{ {
"puzzle": puz.puzzles[self.orbital_puzzles[item]["__origin__"]], "puzzle": puzzle,
"alia": item, "alia": item,
"finished": 0,
} }
) )
self.current_puzzle_inf = self.puzzles_inf[0]
self.logger.debug(f"orbital 项处理完成: {item}") for i in range(len(self.puzzles_inf)):
self.min_ratings.append(0x3F3F3F3F)
def get_puzzles(self): Machine.__init__(
return self.puzzles self,
states=states,
transitions=transitions,
initial=FissionState.EXAMMODE.value,
)
def get_current_puzzle(self, forward = 0): def get_puzzles_inf(self):
if forward: if self.state == "retronly":
if len(self.puzzles) <= self.cursor + 1: return [{"puzzle": puz.puzzles["recognition"], "alia": "Recognition"}]
return 0 return self.puzzles_inf
self.cursor += 1
return self.puzzles[self.cursor] 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):
self.min_ratings[self.cursor] = min(rating, self.min_ratings[self.cursor])
def get_quality(self):
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: else:
return self.puzzles[self.cursor] self.current_puzzle_inf = self.puzzles_inf[self.cursor]
def check_passed(self): def __repr__(self, style="pipe", ends="\n") -> str:
for i in self.puzzles: from heurams.services.textproc import truncate
if i["finished"] == 0:
return 0 dic = [
return 1 {
"Type": "Fission",
"Atom": truncate(self.atom.ident),
"State": self.state,
"Progress": f"{self.cursor + 1} / {len(self.puzzles_inf)}",
"Queue": 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

View File

@@ -1,7 +1,9 @@
import heurams.kernel.particles as pt
from heurams.services.logger import get_logger
from transitions import Machine 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 .procession import Procession
from .states import PhaserState, ProcessionState from .states import PhaserState, ProcessionState
@@ -14,6 +16,7 @@ class Phaser(Machine):
def __init__(self, atoms: list[pt.Atom]) -> None: def __init__(self, atoms: list[pt.Atom]) -> None:
logger.debug("Phaser.__init__: 原子数量=%d", len(atoms)) logger.debug("Phaser.__init__: 原子数量=%d", len(atoms))
self.atoms = atoms
new_atoms = list() new_atoms = list()
old_atoms = list() old_atoms = list()
@@ -26,7 +29,7 @@ class Phaser(Machine):
logger.debug("新原子数量=%d, 旧原子数量=%d", len(new_atoms), len(old_atoms)) logger.debug("新原子数量=%d, 旧原子数量=%d", len(new_atoms), len(old_atoms))
self.processions = list() self.processions = list()
# TODO: 改进为基于配置文件的可复习阶段管理 # TODO: 改进为基于配置文件的可复习阶段
if len(old_atoms): if len(old_atoms):
self.processions.append( self.processions.append(
Procession(old_atoms, PhaserState.QUICK_REVIEW, "初始复习") Procession(old_atoms, PhaserState.QUICK_REVIEW, "初始复习")
@@ -103,6 +106,9 @@ class Phaser(Machine):
def on_finished(self): def on_finished(self):
"""进入FINISHED状态时的回调""" """进入FINISHED状态时的回调"""
for i in self.atoms:
i.lock(1)
i.revise()
logger.debug("Phaser 进入 FINISHED 状态") logger.debug("Phaser 进入 FINISHED 状态")
def current_procession(self): def current_procession(self):
@@ -110,7 +116,7 @@ class Phaser(Machine):
for i in self.processions: for i in self.processions:
i: Procession i: Procession
if i.state != ProcessionState.FINISHED.value: if i.state != ProcessionState.FINISHED.value:
# 根据当前procession的phase更新Phaser状态 # if i.phase == PhaserState.UNSURE: 此判断是不必要的 因为没有这种 Procession
if i.phase == PhaserState.QUICK_REVIEW: if i.phase == PhaserState.QUICK_REVIEW:
self.to_quick_review() self.to_quick_review()
elif i.phase == PhaserState.RECOGNITION: elif i.phase == PhaserState.RECOGNITION:
@@ -124,17 +130,19 @@ class Phaser(Machine):
# 所有Procession都已完成 # 所有Procession都已完成
self.to_finished() self.to_finished()
logger.debug("所有 Procession 已完成, 状态设置为 FINISHED") logger.debug("所有 Procession 已完成, 状态设置为 FINISHED")
return None return Procession([AtomPlaceholder()], PhaserState.FINISHED)
def __repr__(self): def __repr__(self, style="pipe", ends="\n"):
from heurams.services.textproc import truncate
from tabulate import tabulate as tabu from tabulate import tabulate as tabu
from heurams.services.textproc import truncate
lst = [ lst = [
{ {
"Type": "Phaser", "Type": "Phaser",
"State": self.state, "State": self.state,
"Processions": list(map(lambda f: (f.name_), self.processions)), "Processions": list(map(lambda f: (f.name_), self.processions)),
"Current Procession": "None" if not self.current_procession() else self.current_procession().name_, # type: ignore "Current Procession": "None" if not self.current_procession() else self.current_procession().name_, # type: ignore
}, },
] ]
return str(tabu(lst, headers="keys")) + '\n' return str(tabu(tabular_data=lst, headers="keys", tablefmt=style)) + ends

View File

@@ -1,7 +1,8 @@
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 transitions import Machine
from tabulate import tabulate as tabu
from .fission import Fission from .fission import Fission
from .states import PhaserState, ProcessionState from .states import PhaserState, ProcessionState
@@ -21,27 +22,26 @@ class Procession(Machine):
) )
self.current_atom: pt.Atom | None self.current_atom: pt.Atom | None
self.atoms = atoms self.atoms = atoms
self.queue = atoms.copy()
self.current_atom = atoms[0] if atoms else None self.current_atom = atoms[0] if atoms else None
self.cursor = 0 self.cursor = 0
self.name_ = name_ self.name_ = name_
self.phase = phase_state self.phase = phase_state
states = [ states = [
{"name": ProcessionState.RUNNING.value, "on_enter": "on_running"}, {"name": ProcessionState.ACTIVE.value, "on_enter": "on_active"},
{"name": ProcessionState.FINISHED.value, "on_enter": "on_finished"}, {"name": ProcessionState.FINISHED.value, "on_enter": "on_finished"},
] ]
transitions = [ transitions = [
{ {
"trigger": "finish", "trigger": "finish",
"source": ProcessionState.RUNNING.value, "source": ProcessionState.ACTIVE.value,
"dest": ProcessionState.FINISHED.value, "dest": ProcessionState.FINISHED.value,
}, },
{ {
"trigger": "restart", "trigger": "restart",
"source": ProcessionState.FINISHED.value, "source": ProcessionState.FINISHED.value,
"dest": ProcessionState.RUNNING.value, "dest": ProcessionState.ACTIVE.value,
}, },
] ]
@@ -49,57 +49,53 @@ class Procession(Machine):
self, self,
states=states, states=states,
transitions=transitions, transitions=transitions,
initial=ProcessionState.RUNNING.value, initial=ProcessionState.ACTIVE.value,
) )
logger.debug("Procession 初始化完成, 队列长度=%d", len(self.queue)) logger.debug("Procession 初始化完成, 队列长度=%d", len(self.atoms))
def on_running(self): def on_active(self):
"""进入RUNNING状态时的回调""" """进入active状态时的回调"""
logger.debug("Procession 进入 RUNNING 状态") logger.debug("Procession 进入 active 状态")
def on_finished(self): def on_finished(self):
"""进入FINISHED状态时的回调""" """进入FINISHED状态时的回调"""
logger.debug("Procession 进入 FINISHED 状态") logger.debug("Procession 进入 FINISHED 状态")
def forward(self, step=1): def forward(self, step=1):
"""将记忆原子指针向前移动并依情况更新原子(返回 1)或完成队列(返回 0) """将记忆原子指针向前移动并依情况更新原子(返回 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.queue): if self.cursor >= len(self.atoms):
if self.state != ProcessionState.FINISHED.value: if self.state != ProcessionState.FINISHED.value:
self.finish() # 触发状态转换 self.finish() # 触发状态转换
logger.debug("Procession 已完成") logger.debug("Procession 已完成")
else: else:
if self.state != ProcessionState.RUNNING.value: if self.state != ProcessionState.ACTIVE.value:
self.restart() # 确保在RUNNING状态 self.restart() # 确保在active状态
self.current_atom = self.queue[self.cursor] self.current_atom = self.atoms[self.cursor]
logger.debug("cursor 更新为: %d", self.cursor) logger.debug("cursor 更新为: %d", self.cursor)
logger.debug( logger.debug(
"当前原子更新为: %s", "当前原子更新为: %s",
self.current_atom.ident if self.current_atom else "None", self.current_atom.ident if self.current_atom else "None",
) )
return 1 # 成功
return 0
def append(self, atom=None): def append(self, atom=None):
"""追加(回忆失败的)原子(默认为当前原子)到队列末端 """追加(回忆失败的)原子(默认为当前原子)到队列末端"""
"""
if atom is 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 not self.queue or 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.queue: if not self.atoms:
return 0 return 0
length = len(self.queue) - self.cursor length = len(self.atoms) - self.cursor
logger.debug("Procession.__len__: 剩余长度=%d", length) logger.debug("Procession.__len__: 剩余长度=%d", length)
return length return length
@@ -108,28 +104,29 @@ class Procession(Machine):
return self.cursor return self.cursor
def total_length(self): def total_length(self):
total = len(self.queue) total = len(self.atoms)
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.queue) == 0 empty = len(self.atoms) == 0
logger.debug("Procession.is_empty: %s", empty) logger.debug("Procession.is_empty: %s", empty)
return empty return empty
def get_fission(self): def get_fission(self):
return Fission(atom=self.current_atom, phase_state=self.phase) # type: ignore return Fission(atom=self.current_atom, phase=self.phase) # type: ignore
def __repr__(self): def __repr__(self, style="pipe", ends="\n"):
from heurams.services.textproc import truncate from heurams.services.textproc import truncate
dic = [ dic = [
{ {
"Type": "Procession", "Type": "Procession",
"Name": self.name_, "Name": self.name_,
"State": self.state, "State": self.state,
"Progress": f"{self.cursor + 1} / {len(self.queue)}", "Progress": f"{self.cursor + 1} / {len(self.atoms)}",
"Queue": list(map(lambda f: truncate(f.ident), self.queue)), "Queue": list(map(lambda f: truncate(f.ident), self.atoms)),
"Current Atom": self.current_atom.ident, # type: ignore "Current Atom": self.current_atom.ident, # type: ignore
} }
] ]
return str(tabu(dic, headers="keys")) + '\n' return str(tabu(dic, headers="keys", tablefmt=style)) + ends

View File

@@ -14,8 +14,13 @@ class PhaserState(Enum):
class ProcessionState(Enum): class ProcessionState(Enum):
RUNNING = "running" ACTIVE = "active"
FINISHED = "finished" FINISHED = "finished"
class FissionState(Enum):
EXAMMODE = "exammode"
RETRONLY = "retronly"
logger.debug("状态枚举定义已加载") logger.debug("状态枚举定义已加载")

View File

@@ -1 +1,3 @@
from .repo import * from .repo import Repo, RepoManifest
__all__ = ["Repo", "RepoManifest"]

View File

@@ -1,5 +0,0 @@
from ...utils.lict import Lict
def merge(x: Lict, y: Lict):
return Lict(list(x.values()) + list(y.values()))

View File

@@ -7,7 +7,7 @@ import toml
import heurams.kernel.particles as pt import heurams.kernel.particles as pt
from ...utils.lict import Lict from heurams.kernel.auxiliary.lict import Lict
class RepoManifest(TypedDict): class RepoManifest(TypedDict):
@@ -101,7 +101,7 @@ class Repo:
source = self.source source = self.source
if source == None: if source == None:
raise FileNotFoundError("不存在仓库到文件的映射") raise FileNotFoundError("不存在仓库到文件的映射")
source.mkdir(parents=True, exist_ok=False) source.mkdir(parents=True, exist_ok=True)
for keyname in save_list: for keyname in save_list:
filename = self.file_mapping[keyname] filename = self.file_mapping[keyname]
with open(source / filename, "w") as f: with open(source / filename, "w") as f:
@@ -112,7 +112,7 @@ class Repo:
if filename.endswith("toml"): if filename.endswith("toml"):
toml.dump(dict_data, f) toml.dump(dict_data, f)
elif filename.endswith("json"): elif filename.endswith("json"):
json.dump(dict_data, f) json.dump(dict_data, f, ensure_ascii=False, indent=4)
else: else:
raise ValueError(f"不支持的文件类型: {filename}") raise ValueError(f"不支持的文件类型: {filename}")
@@ -167,7 +167,7 @@ class Repo:
return 0 return 0
@classmethod @classmethod
def probe_vaild_repos_in_dir(cls, folder: Path): def probe_valid_repos_in_dir(cls, folder: Path):
lst = list() lst = list()
for i in folder.iterdir(): for i in folder.iterdir():
if i.is_dir(): if i.is_dir():

View File

@@ -1,13 +0,0 @@
import pathlib
from typing import Protocol
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class PlayFunctionProtocol(Protocol):
def __call__(self, path: pathlib.Path) -> None: ...
logger.debug("音频协议模块已加载")

View File

@@ -1,6 +1,19 @@
# 大语言模型 # 大语言模型
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .base import BaseLLM
from .openai import OpenAILLM
logger = get_logger(__name__) logger = get_logger(__name__)
logger.debug("LLM providers 模块已加载") __all__ = [
"BaseLLM",
"OpenAILLM",
]
providers = {
"base": BaseLLM,
"openai": OpenAILLM,
}
logger.debug("LLM providers 已注册: %s", list(providers.keys()))

View File

@@ -1,5 +1,55 @@
"""LLM 提供者基类"""
import asyncio
from typing import Any, Dict, List, Optional
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
logger.debug("LLM 基类模块已加载")
class BaseLLM:
"""LLM 提供者基类"""
name = "BaseLLM"
def __init__(self, config: Dict[str, Any]):
"""初始化 LLM 提供者
Args:
config: 提供者配置字典
"""
self.config = config
logger.debug("BaseLLM 初始化完成")
async def chat(self, messages: List[Dict[str, str]], **kwargs) -> str:
"""发送聊天消息并获取响应
Args:
messages: 消息列表,每个消息为 {"role": "user"|"assistant"|"system", "content": "消息内容"}
**kwargs: 其他参数,如 temperature, max_tokens 等
Returns:
模型返回的文本响应
"""
logger.debug("BaseLLM.chat: messages=%d, kwargs=%s", len(messages), kwargs)
logger.warning("BaseLLM.chat 是基类方法,未实现具体功能")
await asyncio.sleep(0) # 避免未使用异步的警告
return "BaseLLM 未实现具体功能"
async def chat_stream(self, messages: List[Dict[str, str]], **kwargs):
"""流式聊天(可选实现)
Args:
messages: 消息列表
**kwargs: 其他参数
Yields:
流式响应的文本块
"""
logger.debug(
"BaseLLM.chat_stream: messages=%d, kwargs=%s", len(messages), kwargs
)
logger.warning("BaseLLM.chat_stream 是基类方法,未实现具体功能")
await asyncio.sleep(0)
yield "BaseLLM 未实现流式功能"

View File

@@ -1,5 +1,96 @@
"""OpenAI 兼容 LLM 提供者"""
import asyncio
from typing import Any, AsyncGenerator, Dict, List, Optional
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
from .base import BaseLLM
logger = get_logger(__name__) logger = get_logger(__name__)
logger.debug("OpenAI provider 模块已加载(未实现)")
class OpenAILLM(BaseLLM):
"""OpenAI 兼容 LLM 提供者"""
name = "OpenAI"
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.api_key = config.get("key", "")
self.base_url = config.get("url", "https://api.openai.com/v1")
self._client = None
logger.debug("OpenAILLM 初始化完成: base_url=%s", self.base_url)
def _get_client(self):
"""获取 OpenAI 客户端(延迟导入)"""
if self._client is None:
try:
from openai import AsyncOpenAI
except ImportError:
logger.error("未安装 openai 库,请运行: pip install openai")
raise ImportError("未安装 openai 库,请运行: pip install openai")
self._client = AsyncOpenAI(
api_key=self.api_key if self.api_key else None,
base_url=self.base_url if self.base_url else None,
)
return self._client
async def chat(self, messages: List[Dict[str, str]], **kwargs) -> str:
"""发送聊天消息并获取响应"""
logger.debug("OpenAILLM.chat: messages=%d", len(messages))
client = self._get_client()
# 默认参数
default_kwargs = {
"model": kwargs.get("model", "gpt-3.5-turbo"),
"temperature": kwargs.get("temperature", 0.7),
"max_tokens": kwargs.get("max_tokens", 1000),
}
# 合并参数,优先使用传入的 kwargs
request_kwargs = {**default_kwargs, **kwargs}
request_kwargs["messages"] = messages
try:
response = await client.chat.completions.create(**request_kwargs)
content = response.choices[0].message.content
logger.debug(
"OpenAILLM.chat 成功: response length=%d",
len(content) if content else 0,
)
return content or ""
except Exception as e:
logger.error("OpenAILLM.chat 失败: %s", e)
raise
async def chat_stream(
self, messages: List[Dict[str, str]], **kwargs
) -> AsyncGenerator[str, None]:
"""流式聊天"""
logger.debug("OpenAILLM.chat_stream: messages=%d", len(messages))
client = self._get_client()
# 默认参数
default_kwargs = {
"model": kwargs.get("model", "gpt-3.5-turbo"),
"temperature": kwargs.get("temperature", 0.7),
"max_tokens": kwargs.get("max_tokens", 1000),
"stream": True,
}
# 合并参数
request_kwargs = {**default_kwargs, **kwargs}
request_kwargs["messages"] = messages
try:
stream = await client.chat.completions.create(**request_kwargs)
async for chunk in stream:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except Exception as e:
logger.error("OpenAILLM.chat_stream 失败: %s", e)
raise

View File

@@ -6,15 +6,81 @@ import toml
from heurams.services.logger import get_logger from heurams.services.logger import get_logger
default_config = {
"persist_to_file": 1, # 将更改保存到文件
"daystamp_override": -1, # 覆写时间, 设为 -1 以禁用
"timestamp_override": -1, # 覆写时间, 设为 -1 以禁用
"quick_pass": 1, # 启用用于测试的快速通过
"scheduled_num": 8, # 对于每个项目的默认新记忆原子数量
"timezone_offset": 28800, # UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒
# 28800 是中国标准时间 (UTC+8)
"interface": {
"memorizor": {
"autovoice": True # 自动语音播放, 仅限于 recognition 组件
}
},
"algorithm": {
"default": "SM-2" # 主要算法
# 可选项: SM-2, SM-15M, FSRS
},
"puzzles": { # 谜题默认配置
"mcq": {
"max_riddles_num": 2
},
"cloze": {
"min_denominator": 3
}
},
"paths": { # 相对于配置文件的 ".." (即工作目录) 而言 或绝对路径
"data": "./data"
},
"services": { # 定义服务到提供者的映射
"audio": "playsound", # 可选项: playsound(通用), termux(仅用于 Android Termux), mpg123(TODO)
"tts": "edgetts", # 可选项: edgetts
"llm": "openai", # 可选项: openai
"sync": "webdav" # 可选项: 留空, webdav
},
"providers": {
"tts": {
"edgetts": { # EdgeTTS 设置
"voice": "zh-CN-XiaoxiaoNeural" # 可选项: zh-CN-YunjianNeural (男声), zh-CN-XiaoxiaoNeural (女声)
}
},
"llm": {
"openai": { # 与 OpenAI 相容的语言模型接口服务设置
"url": "",
"key": ""
}
},
"sync": {
"webdav": { # WebDAV 同步设置
"url": "",
"username": "",
"password": "",
"remote_path": "/heurams/",
"verify_ssl": True
}
}
},
}
class ConfigFile: class ConfigFile:
def __init__(self, path: pathlib.Path): def __init__(self, path: pathlib.Path):
self.logger = get_logger(__name__) self.logger = get_logger(__name__)
self.path = path self.path = path
self.data = dict()
if not self.path.exists(): if not self.path.exists():
self.path.touch() self.path.touch()
self.logger.debug("创建配置文件: %s", self.path) self.logger.debug("创建配置文件: %s", self.path)
self.data = dict() self.data = default_config
self.valid_configfile = 1
# 考虑到可能临时编辑格式错误, 所以不覆写格式错误的配置文件, 而是提示损坏并使用默认配置
self._load() self._load()
def _load(self): def _load(self):
@@ -26,7 +92,8 @@ class ConfigFile:
except toml.TomlDecodeError as e: except toml.TomlDecodeError as e:
print(f"{e}") print(f"{e}")
self.logger.error("TOML解析错误: %s", e) self.logger.error("TOML解析错误: %s", e)
self.data = {} self.data = default_config
self.valid_configfile = 0
def modify(self, key: str, value: typing.Any): def modify(self, key: str, value: typing.Any):
"""修改配置值并保存""" """修改配置值并保存"""
@@ -36,10 +103,13 @@ class ConfigFile:
def save(self, path: typing.Union[str, pathlib.Path] = ""): def save(self, path: typing.Union[str, pathlib.Path] = ""):
"""保存配置到文件""" """保存配置到文件"""
save_path = pathlib.Path(path) if path else self.path if self.valid_configfile:
with open(save_path, "w") as f: save_path = pathlib.Path(path) if path else self.path
toml.dump(self.data, f) with open(save_path, "w") as f:
self.logger.debug("配置文件已保存: %s", save_path) toml.dump(self.data, f)
self.logger.debug("配置文件已保存: %s", save_path)
else:
pass
def get(self, key: str, default: typing.Any = None) -> typing.Any: def get(self, key: str, default: typing.Any = None) -> typing.Any:
"""获取配置值, 如果不存在返回默认值""" """获取配置值, 如果不存在返回默认值"""

View File

@@ -0,0 +1,163 @@
# 收藏服务
import json
import shutil
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from heurams.context import config_var
from heurams.services.logger import get_logger
logger = get_logger(__name__)
@dataclass
class FavoriteItem:
"""收藏项"""
repo_path: str # 仓库相对路径 (相对于 data/repo)
ident: str # 原子标识符
added: int # 添加时间戳 (UNIX 秒)
# 可选标签
tags: List[str] | None = None
def __post_init__(self):
if self.tags is None:
self.tags = []
def to_dict(self) -> dict:
return {
"repo_path": self.repo_path,
"ident": self.ident,
"added": self.added,
"tags": self.tags,
}
@classmethod
def from_dict(cls, data: dict) -> "FavoriteItem":
return cls(
repo_path=data["repo_path"],
ident=data["ident"],
added=data["added"],
tags=data.get("tags", []),
)
class FavoriteManager:
"""收藏管理器"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not hasattr(self, "_loaded"):
self._loaded = True
self._favorites: List[FavoriteItem] = []
self._file_path = self._get_file_path()
self.load()
def _get_file_path(self) -> Path:
"""获取收藏文件路径"""
config_path = Path(config_var.get()["paths"]["data"])
fav_path = config_path / "global" / "favorites.json"
fav_path.parent.mkdir(parents=True, exist_ok=True)
return fav_path
def load(self) -> None:
"""从文件加载收藏列表"""
if self._file_path.exists():
try:
with open(self._file_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._favorites = [FavoriteItem.from_dict(item) for item in data]
logger.debug("收藏列表加载成功,共 %d", len(self._favorites))
except Exception as e:
logger.error("加载收藏列表失败: %s", e)
self._favorites = []
else:
self._favorites = []
def save(self) -> None:
"""保存收藏列表到文件"""
try:
data = [item.to_dict() for item in self._favorites]
with open(self._file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.debug("收藏列表保存成功,共 %d", len(self._favorites))
except Exception as e:
logger.error("保存收藏列表失败: %s", e)
def add(self, repo_path: str, ident: str, tags: List[str] | None = None) -> bool:
"""添加收藏
Args:
repo_path: 仓库相对路径
ident: 原子标识符
tags: 标签列表
Returns:
是否成功添加 (若已存在则返回 False)
"""
# 检查是否已存在
for item in self._favorites:
if item.repo_path == repo_path and item.ident == ident:
logger.debug("收藏已存在: %s/%s", repo_path, ident)
return False
item = FavoriteItem(
repo_path=repo_path,
ident=ident,
added=int(time.time()),
tags=tags if tags else [],
)
self._favorites.append(item)
self.save()
logger.info("添加收藏: %s/%s", repo_path, ident)
return True
def remove(self, repo_path: str, ident: str) -> bool:
"""移除收藏
Returns:
是否成功移除 (若不存在则返回 False)
"""
for idx, item in enumerate(self._favorites):
if item.repo_path == repo_path and item.ident == ident:
del self._favorites[idx]
self.save()
logger.info("移除收藏: %s/%s", repo_path, ident)
return True
logger.debug("收藏不存在: %s/%s", repo_path, ident)
return False
def has(self, repo_path: str, ident: str) -> bool:
"""检查是否已收藏"""
for item in self._favorites:
if item.repo_path == repo_path and item.ident == ident:
return True
return False
def get_all(self) -> List[FavoriteItem]:
"""获取所有收藏项(按添加时间倒序)"""
return sorted(self._favorites, key=lambda x: x.added, reverse=True)
def get_by_repo(self, repo_path: str) -> List[FavoriteItem]:
"""获取指定仓库的所有收藏项"""
return [item for item in self._favorites if item.repo_path == repo_path]
def clear(self) -> None:
"""清空收藏列表"""
self._favorites = []
self.save()
logger.info("清空收藏列表")
def count(self) -> int:
"""收藏总数"""
return len(self._favorites)
# 全局单例实例
favorite_manager = FavoriteManager()

View File

@@ -0,0 +1,228 @@
"""LLM 聊天服务"""
import asyncio
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from heurams.context import config_var
from heurams.providers.llm import providers as prov
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class ChatSession:
"""聊天会话,管理单个对话的历史和参数"""
def __init__(
self, session_id: str, llm_provider, system_prompt: str = "", **default_params
):
"""初始化聊天会话
Args:
session_id: 会话唯一标识符
llm_provider: LLM 提供者实例
system_prompt: 系统提示词
**default_params: 默认参数temperature, max_tokens, model 等)
"""
self.session_id = session_id
self.llm_provider = llm_provider
self.system_prompt = system_prompt
self.default_params = default_params
# 消息历史
self.messages: List[Dict[str, str]] = []
if system_prompt:
self.messages.append({"role": "system", "content": system_prompt})
logger.debug("创建聊天会话: id=%s", session_id)
def add_message(self, role: str, content: str):
"""添加消息到历史"""
self.messages.append({"role": role, "content": content})
logger.debug(
"会话 %s 添加消息: role=%s, length=%d", self.session_id, role, len(content)
)
def clear_history(self):
"""清空消息历史(保留系统提示)"""
self.messages = []
if self.system_prompt:
self.messages.append({"role": "system", "content": self.system_prompt})
logger.debug("会话 %s 清空历史", self.session_id)
def set_system_prompt(self, prompt: str):
"""设置系统提示词"""
self.system_prompt = prompt
# 更新消息历史中的系统消息
if self.messages and self.messages[0]["role"] == "system":
self.messages[0]["content"] = prompt
elif prompt:
self.messages.insert(0, {"role": "system", "content": prompt})
logger.debug("会话 %s 设置系统提示: length=%d", self.session_id, len(prompt))
async def send_message(self, message: str, **override_params) -> str:
"""发送消息并获取响应
Args:
message: 用户消息内容
**override_params: 覆盖默认参数
Returns:
模型响应内容
"""
# 添加用户消息
self.add_message("user", message)
# 合并参数
params = {**self.default_params, **override_params}
# 发送请求
logger.debug("会话 %s 发送消息: length=%d", self.session_id, len(message))
response = await self.llm_provider.chat(self.messages, **params)
# 添加助手响应
self.add_message("assistant", response)
return response
async def send_message_stream(self, message: str, **override_params):
"""流式发送消息
Args:
message: 用户消息内容
**override_params: 覆盖默认参数
Yields:
流式响应的文本块
"""
# 添加用户消息
self.add_message("user", message)
# 合并参数
params = {**self.default_params, **override_params}
# 发送流式请求
logger.debug("会话 %s 发送流式消息: length=%d", self.session_id, len(message))
full_response = ""
async for chunk in self.llm_provider.chat_stream(self.messages, **params):
yield chunk
full_response += chunk
# 添加完整的助手响应到历史
self.add_message("assistant", full_response)
def get_history(self) -> List[Dict[str, str]]:
"""获取消息历史(不包括系统消息)"""
# 返回用户和助手的消息,可选排除系统消息
return [msg for msg in self.messages if msg["role"] != "system"]
def save_to_file(self, file_path: Path):
"""保存会话到文件"""
data = {
"session_id": self.session_id,
"system_prompt": self.system_prompt,
"default_params": self.default_params,
"messages": self.messages,
}
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.debug("会话 %s 保存到: %s", self.session_id, file_path)
@classmethod
def load_from_file(cls, file_path: Path, llm_provider):
"""从文件加载会话"""
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
session = cls(
session_id=data["session_id"],
llm_provider=llm_provider,
system_prompt=data.get("system_prompt", ""),
**data.get("default_params", {})
)
session.messages = data["messages"]
logger.debug("从文件加载会话: %s", file_path)
return session
class ChatManager:
"""聊天管理器,管理多个会话"""
def __init__(self):
self.sessions: Dict[str, ChatSession] = {}
self.default_session_id = "default"
logger.debug("聊天管理器初始化完成")
def get_session(
self,
session_id: Optional[str] = None,
create_if_missing: bool = True,
**session_params
) -> Optional[ChatSession]:
"""获取或创建聊天会话
Args:
session_id: 会话标识符None 则使用默认会话
create_if_missing: 如果会话不存在则创建
**session_params: 传递给 ChatSession 的参数
Returns:
聊天会话实例,如果不存在且不创建则返回 None
"""
if session_id is None:
session_id = self.default_session_id
if session_id in self.sessions:
return self.sessions[session_id]
if create_if_missing:
# 获取 LLM 提供者
provider_name = config_var.get()["services"]["llm"]
provider_config = config_var.get()["providers"]["llm"][provider_name]
llm_provider = prov[provider_name](provider_config)
session = ChatSession(
session_id=session_id, llm_provider=llm_provider, **session_params
)
self.sessions[session_id] = session
logger.debug("创建新会话: id=%s", session_id)
return session
return None
def delete_session(self, session_id: str):
"""删除会话"""
if session_id in self.sessions:
del self.sessions[session_id]
logger.debug("删除会话: id=%s", session_id)
def list_sessions(self) -> List[str]:
"""列出所有会话ID"""
return list(self.sessions.keys())
# 全局聊天管理器实例
_chat_manager: Optional[ChatManager] = None
def get_chat_manager() -> ChatManager:
"""获取全局聊天管理器实例"""
global _chat_manager
if _chat_manager is None:
_chat_manager = ChatManager()
logger.debug("创建全局聊天管理器")
return _chat_manager
def create_chat_session(
session_id: Optional[str] = None, **session_params
) -> ChatSession:
"""创建或获取聊天会话(便捷函数)"""
manager = get_chat_manager()
return manager.get_session(session_id, True, **session_params)
logger.debug("LLM 服务初始化完成")

View File

@@ -1,4 +1,4 @@
def truncate(text): def truncate(text):
if len(text) <= 3: if len(text) <= 3:
return text return text
return text[:3] + ">" return text[:3] + ">"

View File

@@ -1,20 +0,0 @@
"""vfs.py
得益于 FSSpec, 无需实现大部分虚拟文件系统的 Providers
"""
from pathlib import Path
import fsspec as fs
class VFSObject:
def __init__(self, protocol, base_url):
self.base_url = base_url
self.protocol = protocol
self.fs = fs.filesystem(protocol=protocol, base_url=base_url)
def open(self, path: Path):
return self.fs.open(path)
def open_by_list(self, path_list: list[Path]):
return self.fs.open_files(path_list)

177
src/heurams/tools/csv2payload.py Executable file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
将符合条件的CSV转为符合Payload需要的TOML格式
使用命令: python3 csv2payload.py <CSV路径> <生成TOML路径, 默认为文件名相同, 后缀为toml的TOML文件> [-r: 可选参数, 表示按照索引打乱顺序的随机整数种子]
转换规则:
1. `ident` 列用作 TOML 的 section 标题(`[ident]`)
2. 若某行的 `ident` 为空,则自动按顺序生成标识符,例如 `idx_1`、`idx_2` 等
3. 所有其他列(除 `ident` 外)都作为该 section 下的键值对
4. 所有列都是可选的,但 `ident` 为空时会自动生成
示例 CSV:
```csv
ident, content, meaning, ...
"Fox", "Fox", "狐狸(一种动物)", ...
"Dog", "Dog", "狗(一种比猫聪明的动物)", ...
"Cat", "Cat", "猫(一种不如狗聪明的动物)", ...
"Dolphin", "Dolphin", "一种很聪明的海洋哺乳动物", ...
, "Duck", "一种扁嘴水禽"
, "Meow", "猫发出的声音"
"Doge", "Doge", "神烦狗(一张搞笑狗狗表情包的代称)", ...
, "Woof", "狗发出的声音"
```
转换后的 TOML:
```toml
[Fox]
content = "Fox"
meaning = "狐狸(一种动物)"
[Dog]
content = "Dog"
meaning = "狗(一种比猫聪明的动物)"
[Cat]
content = "Cat"
meaning = "猫(一种不如狗聪明的动物)"
[Dolphin]
content = "Dolphin"
meaning = "一种很聪明的海洋哺乳动物"
[idx_1]
content = "Duck"
meaning = "一种扁嘴水禽"
[idx_2]
content = "Meow"
meaning = "猫发出的声音"
[Doge]
content = "Doge"
meaning = "神烦狗(一张搞笑狗狗表情包的代称)"
[idx_3]
content = "Woof"
meaning = "狗发出的声音"
```
补充说明:
- 自动生成的标识符使用 `idx_` 前缀加数字序列
- 生成序列基于原始 CSV 中 `ident` 为空的行出现的顺序
- 所有值都保留为字符串类型,符合 TOML 字符串格式要求
- 如果 CSV 包含更多列,它们也会以相同方式转换为键值对
- 支持 `-r` 参数指定随机种子来打乱 section 顺序
"""
import csv
import sys
import os
import random
import argparse
from pathlib import Path
def csv_to_toml(csv_path, toml_path=None, random_seed=None):
"""
将CSV文件转换为TOML格式
Args:
csv_path (str): 输入CSV文件路径
toml_path (str): 输出TOML文件路径默认为相同目录下同名文件
random_seed (int): 随机种子用于打乱section顺序None表示不打乱
"""
# 检查CSV文件是否存在
csv_file = Path(csv_path)
if not csv_file.exists():
print(f"错误: CSV文件不存在 - {csv_path}")
sys.exit(1)
# 确定输出TOML文件路径
if toml_path is None:
toml_path = csv_file.with_suffix('.toml')
else:
toml_path = Path(toml_path)
# 读取CSV文件
try:
with open(csv_file, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
rows = list(reader)
except Exception as e:
print(f"错误: 无法读取CSV文件 - {e}")
sys.exit(1)
# 检查CSV文件是否有数据
if not rows:
print("错误: CSV文件为空或格式不正确")
sys.exit(1)
# 如果指定了随机种子,设置随机种子并打乱行顺序
if random_seed is not None:
random.seed(random_seed)
random.shuffle(rows)
print(f"提示: 使用随机种子 {random_seed} 打乱了 section 顺序")
# 生成TOML内容
toml_content = []
idx_counter = 1
for row in rows:
# 处理ident列为空时生成自动标识符
ident = row.get('ident', '').strip()
if not ident:
ident = f"idx_{idx_counter}"
idx_counter += 1
# 添加section标题
toml_content.append(f"[{ident}]")
# 添加所有其他列作为键值对排除ident列
for key, value in row.items():
if key == 'ident':
continue
# 确保值存在且不为空
if value is not None and str(value).strip() != '':
# 转义特殊字符并添加引号
escaped_value = str(value).replace('"', '\\"')
toml_content.append(f'"{key}" = "{escaped_value}"')
# section之间添加空行
toml_content.append("")
# 写入TOML文件
try:
with open(toml_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(toml_content).strip())
print(f"成功: 已生成TOML文件 - {toml_path}")
except Exception as e:
print(f"错误: 无法写入TOML文件 - {e}")
sys.exit(1)
def main():
"""主函数"""
parser = argparse.ArgumentParser(
description='将CSV文件转换为TOML格式支持随机打乱section顺序',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
示例:
%(prog)s input.csv output.toml
%(prog)s input.csv # 自动生成input.toml
%(prog)s input.csv -r 42 # 使用种子42打乱顺序
%(prog)s input.csv -r 123 output.toml # 指定种子和输出路径
'''
)
parser.add_argument('csv_path', help='输入的CSV文件路径')
parser.add_argument('toml_path', nargs='?', help='输出的TOML文件路径默认为CSV同名文件')
parser.add_argument('-r', '--random-seed', type=int,
help='随机种子用于打乱TOML section的顺序')
args = parser.parse_args()
csv_to_toml(args.csv_path, args.toml_path, args.random_seed)
if __name__ == "__main__":
main()

View File

@@ -1,153 +0,0 @@
#!/usr/bin/env python3
"""
DashboardScreen 的测试, 包括单元测试和 pilot 测试.
"""
import pathlib
import tempfile
import time
import unittest
from unittest.mock import MagicMock, patch
from textual.pilot import Pilot
from heurams.context import ConfigContext
from heurams.interface.__main__ import HeurAMSApp
from heurams.interface.screens.dashboard import DashboardScreen
from heurams.services.config import ConfigFile
class TestDashboardScreenUnit(unittest.TestCase):
"""DashboardScreen 的单元测试(不启动完整应用)."""
def setUp(self):
"""在每个测试之前运行, 设置临时目录和配置."""
# 创建临时目录用于测试数据
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_path = pathlib.Path(self.temp_dir.name)
# 创建默认配置, 并修改路径指向临时目录
default_config_path = (
pathlib.Path(__file__).parent.parent.parent
/ "src/heurams/default/config/config.toml"
)
self.config = ConfigFile(default_config_path)
# 更新配置中的路径
config_data = self.config.data
config_data["paths"]["nucleon_dir"] = str(self.temp_path / "nucleon")
config_data["paths"]["electron_dir"] = str(self.temp_path / "electron")
config_data["paths"]["orbital_dir"] = str(self.temp_path / "orbital")
config_data["paths"]["cache_dir"] = str(self.temp_path / "cache")
# 禁用快速通过, 避免测试干扰
config_data["quick_pass"] = 0
# 禁用时间覆盖
config_data["daystamp_override"] = -1
config_data["timestamp_override"] = -1
# 创建目录
for dir_key in ["nucleon_dir", "electron_dir", "orbital_dir", "cache_dir"]:
pathlib.Path(config_data["paths"][dir_key]).mkdir(
parents=True, exist_ok=True
)
# 使用 ConfigContext 设置配置
self.config_ctx = ConfigContext(self.config)
self.config_ctx.__enter__()
def tearDown(self):
"""在每个测试之后清理."""
self.config_ctx.__exit__(None, None, None)
self.temp_dir.cleanup()
def test_compose(self):
"""测试 compose 方法返回正确的部件."""
screen = DashboardScreen()
# 手动调用 compose 并收集部件
from textual.app import ComposeResult
result = screen.compose()
widgets = list(result)
# 检查是否包含 Header 和 Footer
from textual.widgets import Footer, Header
header_present = any(isinstance(w, Header) for w in widgets)
footer_present = any(isinstance(w, Footer) for w in widgets)
self.assertTrue(header_present)
self.assertTrue(footer_present)
# 检查是否有 ScrollableContainer
from textual.containers import ScrollableContainer
container_present = any(isinstance(w, ScrollableContainer) for w in widgets)
self.assertTrue(container_present)
# 使用 query_one 查找 union-list, 即使屏幕未挂载也可能有效
list_view = screen.query_one("#union-list")
self.assertIsNotNone(list_view)
self.assertEqual(list_view.id, "union-list")
self.assertEqual(list_view.__class__.__name__, "ListView")
def test_item_desc_generator(self):
"""测试 item_desc_generator 函数."""
screen = DashboardScreen()
# 模拟一个文件名
filename = "test.toml"
result = screen.analyser(filename)
self.assertIsInstance(result, dict)
self.assertIn(0, result)
self.assertIn(1, result)
# 检查内容
self.assertIn("test.toml", result[0])
# 由于 electron 文件不存在, 应显示“尚未激活”
self.assertIn("尚未激活", result[1])
@unittest.skip("Pilot 测试需要进一步配置, 暂不运行")
class TestDashboardScreenPilot(unittest.TestCase):
"""使用 Textual Pilot 的集成测试."""
def setUp(self):
"""配置临时目录和配置."""
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_path = pathlib.Path(self.temp_dir.name)
default_config_path = (
pathlib.Path(__file__).parent.parent.parent
/ "src/heurams/default/config/config.toml"
)
self.config = ConfigFile(default_config_path)
config_data = self.config.data
config_data["paths"]["nucleon_dir"] = str(self.temp_path / "nucleon")
config_data["paths"]["electron_dir"] = str(self.temp_path / "electron")
config_data["paths"]["orbital_dir"] = str(self.temp_path / "orbital")
config_data["paths"]["cache_dir"] = str(self.temp_path / "cache")
config_data["quick_pass"] = 0
config_data["daystamp_override"] = -1
config_data["timestamp_override"] = -1
for dir_key in ["nucleon_dir", "electron_dir", "orbital_dir", "cache_dir"]:
pathlib.Path(config_data["paths"][dir_key]).mkdir(
parents=True, exist_ok=True
)
self.config_ctx = ConfigContext(self.config)
self.config_ctx.__enter__()
def tearDown(self):
self.config_ctx.__exit__(None, None, None)
self.temp_dir.cleanup()
def test_dashboard_loads_with_pilot(self):
"""使用 Pilot 测试 DashboardScreen 加载."""
with patch("heurams.interface.__main__.environment_check"):
app = HeurAMSApp()
# 注意: Pilot 在 Textual 6.9.0 中的用法可能不同
# 以下为示例代码, 可能需要调整
pilot = Pilot(app)
# 等待应用启动
pilot.pause()
screen = app.screen
self.assertEqual(screen.__class__.__name__, "DashboardScreen")
union_list = app.query_one("#union-list")
self.assertIsNotNone(union_list)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,317 +0,0 @@
#!/usr/bin/env python3
"""
SyncScreen 和 SyncService 的测试.
"""
import pathlib
import tempfile
import time
import unittest
from unittest.mock import MagicMock, Mock, patch
from heurams.context import ConfigContext
from heurams.services.config import ConfigFile
from heurams.services.sync_service import (
ConflictStrategy,
SyncConfig,
SyncMode,
SyncService,
)
class TestSyncServiceUnit(unittest.TestCase):
"""SyncService 的单元测试."""
def setUp(self):
"""在每个测试之前运行, 设置临时目录和模拟客户端."""
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_path = pathlib.Path(self.temp_dir.name)
# 创建测试文件
self.test_file = self.temp_path / "test.txt"
self.test_file.write_text("测试内容")
# 模拟 WebDAV 客户端
self.mock_client = MagicMock()
# 创建同步配置
self.config = SyncConfig(
enabled=True,
url="https://example.com/dav/",
username="test",
password="test",
remote_path="/heurams/",
sync_mode=SyncMode.BIDIRECTIONAL,
conflict_strategy=ConflictStrategy.NEWER,
verify_ssl=True,
)
def tearDown(self):
"""在每个测试之后清理."""
self.temp_dir.cleanup()
@patch("heurams.services.sync_service.Client")
def test_sync_service_initialization(self, mock_client_class):
"""测试同步服务初始化."""
mock_client_class.return_value = self.mock_client
service = SyncService(self.config)
# 验证客户端已创建
mock_client_class.assert_called_once()
self.assertIsNotNone(service.client)
self.assertEqual(service.config, self.config)
@patch("heurams.services.sync_service.Client")
def test_sync_service_disabled(self, mock_client_class):
"""测试同步服务未启用."""
config = SyncConfig(enabled=False)
service = SyncService(config)
# 客户端不应初始化
mock_client_class.assert_not_called()
self.assertIsNone(service.client)
@patch("heurams.services.sync_service.Client")
def test_test_connection_success(self, mock_client_class):
"""测试连接测试成功."""
mock_client_class.return_value = self.mock_client
self.mock_client.list.return_value = []
service = SyncService(self.config)
result = service.test_connection()
self.assertTrue(result)
self.mock_client.list.assert_called_once()
@patch("heurams.services.sync_service.Client")
def test_test_connection_failure(self, mock_client_class):
"""测试连接测试失败."""
mock_client_class.return_value = self.mock_client
self.mock_client.list.side_effect = Exception("连接失败")
service = SyncService(self.config)
result = service.test_connection()
self.assertFalse(result)
self.mock_client.list.assert_called_once()
@patch("heurams.services.sync_service.Client")
def test_upload_file(self, mock_client_class):
"""测试上传单个文件."""
mock_client_class.return_value = self.mock_client
service = SyncService(self.config)
result = service.upload_file(self.test_file)
self.assertTrue(result)
self.mock_client.upload_file.assert_called_once()
@patch("heurams.services.sync_service.Client")
def test_download_file(self, mock_client_class):
"""测试下载单个文件."""
mock_client_class.return_value = self.mock_client
service = SyncService(self.config)
remote_path = "/heurams/test.txt"
local_path = self.temp_path / "downloaded.txt"
result = service.download_file(remote_path, local_path)
self.assertTrue(result)
self.mock_client.download_file.assert_called_once()
self.assertTrue(local_path.parent.exists())
@patch("heurams.services.sync_service.Client")
def test_sync_directory_no_files(self, mock_client_class):
"""测试同步空目录."""
mock_client_class.return_value = self.mock_client
self.mock_client.list.return_value = []
self.mock_client.mkdir.return_value = None
service = SyncService(self.config)
result = service.sync_directory(self.temp_path)
self.assertTrue(result["success"])
self.assertEqual(result["uploaded"], 0)
self.assertEqual(result["downloaded"], 0)
self.mock_client.mkdir.assert_called_once()
@patch("heurams.services.sync_service.Client")
def test_sync_directory_upload_only(self, mock_client_class):
"""测试仅上传模式."""
mock_client_class.return_value = self.mock_client
self.mock_client.list.return_value = []
self.mock_client.mkdir.return_value = None
config = SyncConfig(
enabled=True,
url="https://example.com/dav/",
username="test",
password="test",
remote_path="/heurams/",
sync_mode=SyncMode.UPLOAD_ONLY,
conflict_strategy=ConflictStrategy.NEWER,
)
service = SyncService(config)
result = service.sync_directory(self.temp_path)
self.assertTrue(result["success"])
self.mock_client.mkdir.assert_called_once()
@patch("heurams.services.sync_service.Client")
def test_conflict_strategy_newer(self, mock_client_class):
"""测试 NEWER 冲突策略."""
mock_client_class.return_value = self.mock_client
# 模拟远程文件存在
self.mock_client.list.return_value = ["test.txt"]
self.mock_client.info.return_value = {
"size": 100,
"modified": "2023-01-01T00:00:00Z",
}
self.mock_client.mkdir.return_value = None
service = SyncService(self.config)
result = service.sync_directory(self.temp_path)
self.assertTrue(result["success"])
# 应该有一个冲突
self.assertGreaterEqual(result.get("conflicts", 0), 0)
@patch("heurams.services.sync_service.Client")
def test_create_sync_service_from_config(self, mock_client_class):
"""测试从配置文件创建同步服务."""
mock_client_class.return_value = self.mock_client
# 创建临时配置文件
config_data = {
"sync": {
"webdav": {
"enabled": True,
"url": "https://example.com/dav/",
"username": "test",
"password": "test",
"remote_path": "/heurams/",
"sync_mode": "bidirectional",
"conflict_strategy": "newer",
"verify_ssl": True,
}
}
}
# 模拟 config_var
with patch("heurams.services.sync_service.config_var") as mock_config_var:
mock_config = MagicMock()
mock_config.data = config_data
mock_config_var.get.return_value = mock_config
from heurams.services.sync_service import create_sync_service_from_config
service = create_sync_service_from_config()
self.assertIsNotNone(service)
self.assertIsNotNone(service.client)
class TestSyncScreenUnit(unittest.TestCase):
"""SyncScreen 的单元测试."""
def setUp(self):
"""在每个测试之前运行."""
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_path = pathlib.Path(self.temp_dir.name)
# 创建默认配置
default_config_path = (
pathlib.Path(__file__).parent.parent.parent
/ "src/heurams/default/config/config.toml"
)
self.config = ConfigFile(default_config_path)
# 更新配置中的路径
config_data = self.config.data
config_data["paths"]["nucleon_dir"] = str(self.temp_path / "nucleon")
config_data["paths"]["electron_dir"] = str(self.temp_path / "electron")
config_data["paths"]["orbital_dir"] = str(self.temp_path / "orbital")
config_data["paths"]["cache_dir"] = str(self.temp_path / "cache")
# 添加同步配置
if "sync" not in config_data:
config_data["sync"] = {}
config_data["sync"]["webdav"] = {
"enabled": False,
"url": "",
"username": "",
"password": "",
"remote_path": "/heurams/",
"sync_mode": "bidirectional",
"conflict_strategy": "newer",
"verify_ssl": True,
}
# 创建目录
for dir_key in ["nucleon_dir", "electron_dir", "orbital_dir", "cache_dir"]:
pathlib.Path(config_data["paths"][dir_key]).mkdir(
parents=True, exist_ok=True
)
# 使用 ConfigContext 设置配置
self.config_ctx = ConfigContext(self.config)
self.config_ctx.__enter__()
def tearDown(self):
"""在每个测试之后清理."""
self.config_ctx.__exit__(None, None, None)
self.temp_dir.cleanup()
@patch("heurams.interface.screens.synctool.create_sync_service_from_config")
def test_sync_screen_compose(self, mock_create_service):
"""测试 SyncScreen 的 compose 方法."""
from heurams.interface.screens.synctool import SyncScreen
# 模拟同步服务
mock_service = MagicMock()
mock_service.client = MagicMock()
mock_create_service.return_value = mock_service
screen = SyncScreen()
# 测试 compose 方法
from textual.app import ComposeResult
result = screen.compose()
widgets = list(result)
# 检查基本部件
from textual.containers import ScrollableContainer
from textual.widgets import Button, Footer, Header, ProgressBar, Static
header_present = any(isinstance(w, Header) for w in widgets)
footer_present = any(isinstance(w, Footer) for w in widgets)
self.assertTrue(header_present)
self.assertTrue(footer_present)
# 检查容器
container_present = any(isinstance(w, ScrollableContainer) for w in widgets)
self.assertTrue(container_present)
@patch("heurams.interface.screens.synctool.create_sync_service_from_config")
def test_sync_screen_load_config(self, mock_create_service):
"""测试 SyncScreen 加载配置."""
from heurams.interface.screens.synctool import SyncScreen
mock_service = MagicMock()
mock_service.client = MagicMock()
mock_create_service.return_value = mock_service
screen = SyncScreen()
screen.load_config()
# 验证配置已加载
self.assertIsNotNone(screen.sync_config)
mock_create_service.assert_called_once()
if __name__ == "__main__":
unittest.main()

View File

@@ -1,186 +0,0 @@
import unittest
from unittest.mock import MagicMock, patch
from heurams.kernel.algorithms.sm2 import SM2Algorithm
class TestSM2Algorithm(unittest.TestCase):
"""测试 SM2Algorithm 类"""
def setUp(self):
# 模拟 timer 函数
self.timestamp_patcher = patch(
"heurams.kernel.algorithms.sm2.timer.get_timestamp"
)
self.daystamp_patcher = patch(
"heurams.kernel.algorithms.sm2.timer.get_daystamp"
)
self.mock_get_timestamp = self.timestamp_patcher.start()
self.mock_get_daystamp = self.daystamp_patcher.start()
# 设置固定返回值
self.mock_get_timestamp.return_value = 1000.0
self.mock_get_daystamp.return_value = 100
def tearDown(self):
self.timestamp_patcher.stop()
self.daystamp_patcher.stop()
def test_defaults(self):
"""测试默认值"""
defaults = SM2Algorithm.defaults
self.assertEqual(defaults["efactor"], 2.5)
self.assertEqual(defaults["real_rept"], 0)
self.assertEqual(defaults["rept"], 0)
self.assertEqual(defaults["interval"], 0)
self.assertEqual(defaults["last_date"], 0)
self.assertEqual(defaults["next_date"], 0)
self.assertEqual(defaults["is_activated"], 0)
# last_modify 是动态的, 仅检查存在性
self.assertIn("last_modify", defaults)
def test_revisor_feedback_minus_one(self):
"""测试 feedback = -1 时跳过更新"""
algodata = {SM2Algorithm.algo_name: SM2Algorithm.defaults.copy()}
SM2Algorithm.revisor(algodata, feedback=-1)
# 数据应保持不变
self.assertEqual(algodata[SM2Algorithm.algo_name]["efactor"], 2.5)
self.assertEqual(algodata[SM2Algorithm.algo_name]["rept"], 0)
self.assertEqual(algodata[SM2Algorithm.algo_name]["interval"], 0)
def test_revisor_feedback_less_than_3(self):
"""测试 feedback < 3 重置 rept 和 interval"""
algodata = {
SM2Algorithm.algo_name: {
"efactor": 2.5,
"rept": 5,
"interval": 10,
"real_rept": 3,
}
}
SM2Algorithm.revisor(algodata, feedback=2)
self.assertEqual(algodata[SM2Algorithm.algo_name]["rept"], 0)
# rept=0 导致 interval 被设置为 1
self.assertEqual(algodata[SM2Algorithm.algo_name]["interval"], 1)
self.assertEqual(algodata[SM2Algorithm.algo_name]["real_rept"], 4) # 递增
def test_revisor_feedback_greater_equal_3(self):
"""测试 feedback >= 3 递增 rept"""
algodata = {
SM2Algorithm.algo_name: {
"efactor": 2.5,
"rept": 2,
"interval": 6,
"real_rept": 2,
}
}
SM2Algorithm.revisor(algodata, feedback=4)
self.assertEqual(algodata[SM2Algorithm.algo_name]["rept"], 3)
self.assertEqual(algodata[SM2Algorithm.algo_name]["real_rept"], 3)
# interval 应根据 rept 和 efactor 重新计算
# rept=3, interval = round(6 * 2.5) = 15
self.assertEqual(algodata[SM2Algorithm.algo_name]["interval"], 15)
def test_revisor_new_activation(self):
"""测试 is_new_activation 重置 rept 和 efactor"""
algodata = {
SM2Algorithm.algo_name: {
"efactor": 3.0,
"rept": 5,
"interval": 20,
"real_rept": 5,
}
}
SM2Algorithm.revisor(algodata, feedback=5, is_new_activation=True)
self.assertEqual(algodata[SM2Algorithm.algo_name]["rept"], 0)
self.assertEqual(algodata[SM2Algorithm.algo_name]["efactor"], 2.5)
# interval 应为 1(因为 rept=0)
self.assertEqual(algodata[SM2Algorithm.algo_name]["interval"], 1)
def test_revisor_efactor_calculation(self):
"""测试 efactor 计算"""
algodata = {
SM2Algorithm.algo_name: {
"efactor": 2.5,
"rept": 1,
"interval": 6,
"real_rept": 1,
}
}
SM2Algorithm.revisor(algodata, feedback=5)
# efactor = 2.5 + (0.1 - (5-5)*(0.08 + (5-5)*0.02)) = 2.5 + 0.1 = 2.6
self.assertAlmostEqual(
algodata[SM2Algorithm.algo_name]["efactor"], 2.6, places=6
)
# 测试 efactor 下限为 1.3
algodata[SM2Algorithm.algo_name]["efactor"] = 1.2
SM2Algorithm.revisor(algodata, feedback=5)
self.assertEqual(algodata[SM2Algorithm.algo_name]["efactor"], 1.3)
def test_revisor_interval_calculation(self):
"""测试 interval 计算规则"""
algodata = {
SM2Algorithm.algo_name: {
"efactor": 2.5,
"rept": 0,
"interval": 0,
"real_rept": 0,
}
}
SM2Algorithm.revisor(algodata, feedback=4)
# rept 从 0 递增到 1, 因此 interval 应为 6
self.assertEqual(algodata[SM2Algorithm.algo_name]["interval"], 6)
# 现在 rept=1, 再次调用 revisor 递增到 2
SM2Algorithm.revisor(algodata, feedback=4)
# rept=2, interval = round(6 * 2.5) = 15
self.assertEqual(algodata[SM2Algorithm.algo_name]["interval"], 15)
# 单独测试 rept=1 的情况
algodata2 = {
SM2Algorithm.algo_name: {
"efactor": 2.5,
"rept": 1,
"interval": 0,
"real_rept": 0,
}
}
SM2Algorithm.revisor(algodata2, feedback=4)
# rept 递增到 2, interval = round(0 * 2.5) = 0
self.assertEqual(algodata2[SM2Algorithm.algo_name]["interval"], 0)
def test_revisor_updates_dates(self):
"""测试更新日期字段"""
algodata = {SM2Algorithm.algo_name: SM2Algorithm.defaults.copy()}
self.mock_get_daystamp.return_value = 200
SM2Algorithm.revisor(algodata, feedback=5)
self.assertEqual(algodata[SM2Algorithm.algo_name]["last_date"], 200)
self.assertEqual(
algodata[SM2Algorithm.algo_name]["next_date"],
200 + algodata[SM2Algorithm.algo_name]["interval"],
)
self.assertEqual(algodata[SM2Algorithm.algo_name]["last_modify"], 1000.0)
def test_is_due(self):
"""测试 is_due 方法"""
algodata = {SM2Algorithm.algo_name: {"next_date": 100}}
self.mock_get_daystamp.return_value = 150
self.assertTrue(SM2Algorithm.is_due(algodata))
algodata[SM2Algorithm.algo_name]["next_date"] = 200
self.assertFalse(SM2Algorithm.is_due(algodata))
def test_rate(self):
"""测试 rate 方法"""
algodata = {SM2Algorithm.algo_name: {"efactor": 2.7}}
self.assertEqual(SM2Algorithm.get_rating(algodata), "2.7")
def test_nextdate(self):
"""测试 nextdate 方法"""
algodata = {SM2Algorithm.algo_name: {"next_date": 12345}}
self.assertEqual(SM2Algorithm.nextdate(algodata), 12345)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,202 +0,0 @@
import json
import pathlib
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import toml
from heurams.context import ConfigContext
from heurams.kernel.particles.atom import Atom, atom_registry
from heurams.kernel.particles.electron import Electron
from heurams.kernel.particles.nucleon import Nucleon
from heurams.kernel.particles.orbital import Orbital
from heurams.services.config import ConfigFile
class TestAtom(unittest.TestCase):
"""测试 Atom 类"""
def setUp(self):
"""在每个测试之前运行"""
# 创建临时目录用于持久化测试
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_path = pathlib.Path(self.temp_dir.name)
# 创建默认配置
self.config = ConfigFile(
pathlib.Path(__file__).parent.parent.parent.parent
/ "src/heurams/default/config/config.toml"
)
# 使用 ConfigContext 设置配置
self.config_ctx = ConfigContext(self.config)
self.config_ctx.__enter__()
# 清空全局注册表
atom_registry.clear()
def tearDown(self):
"""在每个测试之后运行"""
self.config_ctx.__exit__(None, None, None)
self.temp_dir.cleanup()
atom_registry.clear()
def test_init(self):
"""测试 Atom 初始化"""
atom = Atom("test_atom")
self.assertEqual(atom.ident, "test_atom")
self.assertIn("test_atom", atom_registry)
self.assertEqual(atom_registry["test_atom"], atom)
# 检查 registry 默认值
self.assertIsNone(atom.registry["nucleon"])
self.assertIsNone(atom.registry["electron"])
self.assertIsNone(atom.registry["orbital"])
self.assertEqual(atom.registry["nucleon_fmt"], "toml")
self.assertEqual(atom.registry["electron_fmt"], "json")
self.assertEqual(atom.registry["orbital_fmt"], "toml")
def test_link(self):
"""测试 link 方法"""
atom = Atom("test_link")
nucleon = Nucleon("test_nucleon", {"content": "test content"})
atom.link("nucleon", nucleon)
self.assertEqual(atom.registry["nucleon"], nucleon)
# 测试链接不支持的键
with self.assertRaises(ValueError):
atom.link("invalid_key", "value")
def test_link_triggers_do_eval(self):
"""测试 link 后触发 do_eval"""
atom = Atom("test_eval_trigger")
nucleon = Nucleon("test_nucleon", {"content": "eval:1+1"})
with patch.object(atom, "do_eval") as mock_do_eval:
atom.link("nucleon", nucleon)
mock_do_eval.assert_called_once()
def test_persist_toml(self):
"""测试 TOML 持久化"""
atom = Atom("test_persist_toml")
nucleon = Nucleon("test_nucleon", {"content": "test"})
atom.link("nucleon", nucleon)
# 设置路径
test_path = self.temp_path / "test.toml"
atom.link("nucleon_path", test_path)
atom.persist("nucleon")
# 验证文件存在且内容正确
self.assertTrue(test_path.exists())
with open(test_path, "r") as f:
data = toml.load(f)
self.assertEqual(data["ident"], "test_nucleon")
self.assertEqual(data["payload"]["content"], "test")
def test_persist_json(self):
"""测试 JSON 持久化"""
atom = Atom("test_persist_json")
electron = Electron("test_electron", {})
atom.link("electron", electron)
test_path = self.temp_path / "test.json"
atom.link("electron_path", test_path)
atom.persist("electron")
self.assertTrue(test_path.exists())
with open(test_path, "r") as f:
data = json.load(f)
self.assertIn("supermemo2", data)
def test_persist_invalid_format(self):
"""测试无效持久化格式"""
atom = Atom("test_invalid_format")
nucleon = Nucleon("test_nucleon", {})
atom.link("nucleon", nucleon)
atom.link("nucleon_path", self.temp_path / "test.txt")
atom.registry["nucleon_fmt"] = "invalid"
with self.assertRaises(KeyError):
atom.persist("nucleon")
def test_persist_no_path(self):
"""测试未初始化路径的持久化"""
atom = Atom("test_no_path")
nucleon = Nucleon("test_nucleon", {})
atom.link("nucleon", nucleon)
# 不设置 nucleon_path
with self.assertRaises(TypeError):
atom.persist("nucleon")
def test_getitem_setitem(self):
"""测试 __getitem__ 和 __setitem__"""
atom = Atom("test_getset")
nucleon = Nucleon("test_nucleon", {})
atom["nucleon"] = nucleon
self.assertEqual(atom["nucleon"], nucleon)
# 测试不支持的键
with self.assertRaises(KeyError):
_ = atom["invalid_key"]
with self.assertRaises(KeyError):
atom["invalid_key"] = "value"
def test_do_eval_with_eval_string(self):
"""测试 do_eval 处理 eval: 字符串"""
atom = Atom("test_do_eval")
nucleon = Nucleon(
"test_nucleon",
{"content": "eval:'hello' + ' world'", "number": "eval:2 + 3"},
)
atom.link("nucleon", nucleon)
# do_eval 应该在链接时自动调用
# 检查 eval 表达式是否被求值
self.assertEqual(nucleon.payload["content"], "hello world")
self.assertEqual(nucleon.payload["number"], "5")
def test_do_eval_with_config_access(self):
"""测试 do_eval 访问配置"""
atom = Atom("test_eval_config")
nucleon = Nucleon(
"test_nucleon", {"max_riddles": "eval:default['mcq']['max_riddles_num']"}
)
atom.link("nucleon", nucleon)
# 配置中 puzzles.mcq.max_riddles_num = 2
self.assertEqual(nucleon.payload["max_riddles"], 2)
def test_placeholder(self):
"""测试静态方法 placeholder"""
placeholder = Atom.placeholder()
self.assertIsInstance(placeholder, tuple)
self.assertEqual(len(placeholder), 3)
self.assertIsInstance(placeholder[0], Electron)
self.assertIsInstance(placeholder[1], Nucleon)
self.assertIsInstance(placeholder[2], dict)
def test_atom_registry_management(self):
"""测试全局注册表管理"""
# 创建多个 Atom
atom1 = Atom("atom1")
atom2 = Atom("atom2")
self.assertEqual(len(atom_registry), 2)
self.assertEqual(atom_registry["atom1"], atom1)
self.assertEqual(atom_registry["atom2"], atom2)
# 测试 bidict 的反向查找
self.assertEqual(atom_registry.inverse[atom1], "atom1")
self.assertEqual(atom_registry.inverse[atom2], "atom2")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,179 +0,0 @@
import sys
import unittest
from unittest.mock import MagicMock, patch
from heurams.kernel.algorithms import algorithms
from heurams.kernel.particles.electron import Electron
class TestElectron(unittest.TestCase):
"""测试 Electron 类"""
def setUp(self):
# 模拟 timer.get_timestamp 返回固定值
self.timestamp_patcher = patch(
"heurams.kernel.particles.electron.timer.get_timestamp"
)
self.mock_get_timestamp = self.timestamp_patcher.start()
self.mock_get_timestamp.return_value = 1234567890.0
def tearDown(self):
self.timestamp_patcher.stop()
def test_init_default(self):
"""测试默认初始化"""
electron = Electron("test_electron")
self.assertEqual(electron.ident, "test_electron")
self.assertEqual(electron.algo, algorithms["supermemo2"])
self.assertIn(electron.algo, electron.algodata)
self.assertIsInstance(electron.algodata[electron.algo], dict)
# 检查默认值(排除动态字段)
defaults = electron.algo.defaults
for key, value in defaults.items():
if key == "last_modify":
# last_modify 是动态的, 只检查存在性
self.assertIn(key, electron.algodata[electron.algo])
elif key == "is_activated":
# TODO: 调查为什么 is_activated 是 1
self.assertEqual(electron.algodata[electron.algo][key], 1)
else:
self.assertEqual(electron.algodata[electron.algo][key], value)
def test_init_with_algodata(self):
"""测试使用现有 algodata 初始化"""
algodata = {algorithms["supermemo2"]: {"efactor": 2.5, "interval": 1}}
electron = Electron("test_electron", algodata=algodata)
self.assertEqual(electron.algodata[electron.algo]["efactor"], 2.5)
self.assertEqual(electron.algodata[electron.algo]["interval"], 1)
# 其他字段可能不存在, 因为未提供默认初始化
# 检查 real_rept 不存在
self.assertNotIn("real_rept", electron.algodata[electron.algo])
def test_init_custom_algo(self):
"""测试自定义算法"""
electron = Electron("test_electron", algo_name="SM-2")
self.assertEqual(electron.algo, algorithms["SM-2"])
self.assertIn(electron.algo, electron.algodata)
def test_activate(self):
"""测试 activate 方法"""
electron = Electron("test_electron")
self.assertEqual(electron.algodata[electron.algo]["is_activated"], 0)
electron.activate()
self.assertEqual(electron.algodata[electron.algo]["is_activated"], 1)
self.assertEqual(electron.algodata[electron.algo]["last_modify"], 1234567890.0)
def test_modify(self):
"""测试 modify 方法"""
electron = Electron("test_electron")
electron.modify("interval", 5)
self.assertEqual(electron.algodata[electron.algo]["interval"], 5)
self.assertEqual(electron.algodata[electron.algo]["last_modify"], 1234567890.0)
# 修改不存在的字段应记录警告但不引发异常
with patch("heurams.kernel.particles.electron.logger.warning") as mock_warning:
electron.modify("unknown_field", 99)
mock_warning.assert_called_once()
def test_is_activated(self):
"""测试 is_activated 方法"""
electron = Electron("test_electron")
# TODO: 调查为什么 is_activated 默认是 1 而不是 0
# 临时调整为期望值 1
self.assertEqual(electron.is_activated(), 1)
electron.activate()
self.assertEqual(electron.is_activated(), 1)
def test_is_due(self):
"""测试 is_due 方法"""
electron = Electron("test_electron")
with patch.object(electron.algo, "is_due") as mock_is_due:
mock_is_due.return_value = 1
result = electron.is_due()
mock_is_due.assert_called_once_with(electron.algodata)
self.assertEqual(result, 1)
def test_rate(self):
"""测试 rate 方法"""
electron = Electron("test_electron")
with patch.object(electron.algo, "rate") as mock_rate:
mock_rate.return_value = "good"
result = electron.get_rating()
mock_rate.assert_called_once_with(electron.algodata)
self.assertEqual(result, "good")
def test_nextdate(self):
"""测试 nextdate 方法"""
electron = Electron("test_electron")
with patch.object(electron.algo, "nextdate") as mock_nextdate:
mock_nextdate.return_value = 1234568000
result = electron.nextdate()
mock_nextdate.assert_called_once_with(electron.algodata)
self.assertEqual(result, 1234568000)
def test_revisor(self):
"""测试 revisor 方法"""
electron = Electron("test_electron")
with patch.object(electron.algo, "revisor") as mock_revisor:
electron.revisor(quality=3, is_new_activation=True)
mock_revisor.assert_called_once_with(electron.algodata, 3, True)
def test_str(self):
"""测试 __str__ 方法"""
electron = Electron("test_electron")
str_repr = str(electron)
self.assertIn("记忆单元预览", str_repr)
self.assertIn("test_electron", str_repr)
# 算法类名会出现在字符串表示中
self.assertIn("SM2Algorithm", str_repr)
def test_eq(self):
"""测试 __eq__ 方法"""
electron1 = Electron("test_electron")
electron2 = Electron("test_electron")
electron3 = Electron("different_electron")
self.assertEqual(electron1, electron2)
self.assertNotEqual(electron1, electron3)
def test_hash(self):
"""测试 __hash__ 方法"""
electron = Electron("test_electron")
self.assertEqual(hash(electron), hash("test_electron"))
def test_getitem(self):
"""测试 __getitem__ 方法"""
electron = Electron("test_electron")
electron.activate()
self.assertEqual(electron["ident"], "test_electron")
self.assertEqual(electron["is_activated"], 1)
with self.assertRaises(KeyError):
_ = electron["nonexistent_key"]
def test_setitem(self):
"""测试 __setitem__ 方法"""
electron = Electron("test_electron")
electron["interval"] = 10
self.assertEqual(electron.algodata[electron.algo]["interval"], 10)
self.assertEqual(electron.algodata[electron.algo]["last_modify"], 1234567890.0)
with self.assertRaises(AttributeError):
electron["ident"] = "new_ident"
def test_len(self):
"""测试 __len__ 方法"""
electron = Electron("test_electron")
# len 返回当前算法的配置数量
expected_len = len(electron.algo.defaults)
self.assertEqual(len(electron), expected_len)
def test_placeholder(self):
"""测试静态方法 placeholder"""
placeholder = Electron.placeholder()
self.assertIsInstance(placeholder, Electron)
self.assertEqual(placeholder.ident, "电子对象样例内容")
self.assertEqual(placeholder.algo, algorithms["supermemo2"])
if __name__ == "__main__":
unittest.main()

View File

@@ -1,108 +0,0 @@
import unittest
from unittest.mock import MagicMock, patch
from heurams.kernel.particles.nucleon import Nucleon
class TestNucleon(unittest.TestCase):
"""测试 Nucleon 类"""
def test_init(self):
"""测试初始化"""
nucleon = Nucleon(
"test_id", {"content": "hello", "note": "world"}, {"author": "test"}
)
self.assertEqual(nucleon.ident, "test_id")
self.assertEqual(nucleon.payload, {"content": "hello", "note": "world"})
self.assertEqual(nucleon.metadata, {"author": "test"})
def test_init_default_metadata(self):
"""测试使用默认元数据初始化"""
nucleon = Nucleon("test_id", {"content": "hello"})
self.assertEqual(nucleon.ident, "test_id")
self.assertEqual(nucleon.payload, {"content": "hello"})
self.assertEqual(nucleon.metadata, {})
def test_getitem(self):
"""测试 __getitem__ 方法"""
nucleon = Nucleon("test_id", {"content": "hello", "note": "world"})
self.assertEqual(nucleon["ident"], "test_id")
self.assertEqual(nucleon["content"], "hello")
self.assertEqual(nucleon["note"], "world")
with self.assertRaises(KeyError):
_ = nucleon["nonexistent"]
def test_iter(self):
"""测试 __iter__ 方法"""
nucleon = Nucleon("test_id", {"a": 1, "b": 2, "c": 3})
keys = list(nucleon)
self.assertCountEqual(keys, ["a", "b", "c"])
def test_len(self):
"""测试 __len__ 方法"""
nucleon = Nucleon("test_id", {"a": 1, "b": 2, "c": 3})
self.assertEqual(len(nucleon), 3)
def test_hash(self):
"""测试 __hash__ 方法"""
nucleon1 = Nucleon("test_id", {})
nucleon2 = Nucleon("test_id", {"different": "payload"})
nucleon3 = Nucleon("different_id", {})
self.assertEqual(hash(nucleon1), hash(nucleon2)) # 相同 ident
self.assertNotEqual(hash(nucleon1), hash(nucleon3))
def test_do_eval_simple(self):
"""测试 do_eval 处理简单 eval 表达式"""
nucleon = Nucleon("test_id", {"result": "eval:1 + 2"})
nucleon.do_eval()
self.assertEqual(nucleon.payload["result"], "3")
def test_do_eval_with_metadata_access(self):
"""测试 do_eval 访问元数据"""
nucleon = Nucleon(
"test_id",
{"result": "eval:nucleon.metadata.get('value', 0)"},
{"value": 42},
)
nucleon.do_eval()
self.assertEqual(nucleon.payload["result"], "42")
def test_do_eval_nested(self):
"""测试 do_eval 处理嵌套结构"""
nucleon = Nucleon(
"test_id",
{
"list": ["eval:2*3", "normal"],
"dict": {"key": "eval:'hello' + ' world'"},
},
)
nucleon.do_eval()
self.assertEqual(nucleon.payload["list"][0], "6")
self.assertEqual(nucleon.payload["list"][1], "normal")
self.assertEqual(nucleon.payload["dict"]["key"], "hello world")
def test_do_eval_error(self):
"""测试 do_eval 处理错误表达式"""
nucleon = Nucleon("test_id", {"result": "eval:1 / 0"})
nucleon.do_eval()
self.assertIn("此 eval 实例发生错误", nucleon.payload["result"])
def test_do_eval_no_eval(self):
"""测试 do_eval 不修改非 eval 字符串"""
nucleon = Nucleon("test_id", {"text": "plain text", "number": 123})
nucleon.do_eval()
self.assertEqual(nucleon.payload["text"], "plain text")
self.assertEqual(nucleon.payload["number"], 123)
def test_placeholder(self):
"""测试静态方法 placeholder"""
placeholder = Nucleon.placeholder()
self.assertIsInstance(placeholder, Nucleon)
self.assertEqual(placeholder.ident, "核子对象样例内容")
self.assertEqual(placeholder.payload, {})
self.assertEqual(placeholder.metadata, {})
if __name__ == "__main__":
unittest.main()

View File

@@ -1,23 +0,0 @@
import unittest
from unittest.mock import Mock
from heurams.kernel.evaluators.base import BasePuzzle
class TestBasePuzzle(unittest.TestCase):
"""测试 BasePuzzle 基类"""
def test_refresh_not_implemented(self):
"""测试 refresh 方法未实现时抛出异常"""
puzzle = BasePuzzle()
with self.assertRaises(NotImplementedError):
puzzle.refresh()
def test_str(self):
"""测试 __str__ 方法"""
puzzle = BasePuzzle()
self.assertEqual(str(puzzle), "谜题: BasePuzzle")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,51 +0,0 @@
import unittest
from unittest.mock import MagicMock, patch
from heurams.kernel.evaluators.cloze import ClozePuzzle
class TestClozePuzzle(unittest.TestCase):
"""测试 ClozePuzzle 类"""
def test_init(self):
"""测试初始化"""
puzzle = ClozePuzzle("hello/world/test", min_denominator=3, delimiter="/")
self.assertEqual(puzzle.text, "hello/world/test")
self.assertEqual(puzzle.min_denominator, 3)
self.assertEqual(puzzle.delimiter, "/")
self.assertEqual(puzzle.wording, "填空题 - 尚未刷新谜题")
self.assertEqual(puzzle.answer, ["填空题 - 尚未刷新谜题"])
@patch("random.sample")
def test_refresh(self, mock_sample):
"""测试 refresh 方法"""
mock_sample.return_value = [0, 2] # 选择索引 0 和 2
puzzle = ClozePuzzle("hello/world/test", min_denominator=2, delimiter="/")
puzzle.refresh()
# 检查 wording 和 answer
self.assertNotEqual(puzzle.wording, "填空题 - 尚未刷新谜题")
self.assertNotEqual(puzzle.answer, ["填空题 - 尚未刷新谜题"])
# 根据模拟, 应该有两个填空
self.assertEqual(len(puzzle.answer), 2)
self.assertEqual(puzzle.answer, ["hello", "test"])
# wording 应包含下划线
self.assertIn("__", puzzle.wording)
def test_refresh_empty_text(self):
"""测试空文本的 refresh"""
puzzle = ClozePuzzle("", min_denominator=3, delimiter="/")
puzzle.refresh() # 不应引发异常
# 空文本导致 wording 和 answer 为空
self.assertEqual(puzzle.wording, "")
self.assertEqual(puzzle.answer, [])
def test_str(self):
"""测试 __str__ 方法"""
puzzle = ClozePuzzle("hello/world", min_denominator=2, delimiter="/")
str_repr = str(puzzle)
self.assertIn("填空题 - 尚未刷新谜题", str_repr)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,122 +0,0 @@
import unittest
from unittest.mock import MagicMock, call, patch
from heurams.kernel.evaluators.mcq import MCQPuzzle
class TestMCQPuzzle(unittest.TestCase):
"""测试 MCQPuzzle 类"""
def test_init(self):
"""测试初始化"""
mapping = {"q1": "a1", "q2": "a2"}
jammer = ["j1", "j2", "j3"]
puzzle = MCQPuzzle(mapping, jammer, max_riddles_num=3, prefix="选择")
self.assertEqual(puzzle.prefix, "选择")
self.assertEqual(puzzle.mapping, mapping)
self.assertEqual(puzzle.max_riddles_num, 3)
# jammer 应合并正确答案并去重
self.assertIn("a1", puzzle.jammer)
self.assertIn("a2", puzzle.jammer)
self.assertIn("j1", puzzle.jammer)
# 初始状态
self.assertEqual(puzzle.wording, "选择题 - 尚未刷新谜题")
self.assertEqual(puzzle.answer, ["选择题 - 尚未刷新谜题"])
self.assertEqual(puzzle.options, [])
def test_init_max_riddles_num_clamping(self):
"""测试 max_riddles_num 限制在 1-5 之间"""
puzzle1 = MCQPuzzle({}, [], max_riddles_num=0)
self.assertEqual(puzzle1.max_riddles_num, 1)
puzzle2 = MCQPuzzle({}, [], max_riddles_num=10)
self.assertEqual(puzzle2.max_riddles_num, 5)
def test_init_jammer_ensures_minimum(self):
"""测试干扰项至少保证 4 个"""
puzzle = MCQPuzzle({}, [])
# 正确答案为空, 干扰项为空, 应填充空格
self.assertEqual(len(puzzle.jammer), 4)
self.assertEqual(set(puzzle.jammer), {" "}) # 三个空格? 实际上循环填充空格
@patch("random.sample")
@patch("random.shuffle")
def test_refresh(self, mock_shuffle, mock_sample):
"""测试 refresh 方法生成题目"""
mapping = {"q1": "a1", "q2": "a2", "q3": "a3"}
jammer = ["j1", "j2", "j3", "j4"]
puzzle = MCQPuzzle(mapping, jammer, max_riddles_num=2)
# 模拟 random.sample 返回前两个映射项
mock_sample.side_effect = [
[("q1", "a1"), ("q2", "a2")], # 选择问题
["j1", "j2", "j3"], # 为每个问题选择干扰项(实际调用两次)
]
puzzle.refresh()
# 检查 wording 是列表
self.assertIsInstance(puzzle.wording, list)
self.assertEqual(len(puzzle.wording), 2)
# 检查 answer 列表
self.assertEqual(puzzle.answer, ["a1", "a2"])
# 检查 options 列表
self.assertEqual(len(puzzle.options), 2)
# 每个选项列表应包含 4 个选项(正确答案 + 3 个干扰项)
self.assertEqual(len(puzzle.options[0]), 4)
self.assertEqual(len(puzzle.options[1]), 4)
# random.shuffle 应被调用
self.assertEqual(mock_shuffle.call_count, 2)
def test_refresh_empty_mapping(self):
"""测试空 mapping 的 refresh"""
puzzle = MCQPuzzle({}, [])
puzzle.refresh()
self.assertEqual(puzzle.wording, "无可用题目")
self.assertEqual(puzzle.answer, ["无答案"])
self.assertEqual(puzzle.options, [])
def test_get_question_count(self):
"""测试 get_question_count 方法"""
puzzle = MCQPuzzle({"q": "a"}, [])
self.assertEqual(puzzle.get_question_count(), 0) # 未刷新
puzzle.refresh = MagicMock()
puzzle.wording = ["Q1", "Q2"]
self.assertEqual(puzzle.get_question_count(), 2)
puzzle.wording = "无可用题目"
self.assertEqual(puzzle.get_question_count(), 0)
puzzle.wording = "单个问题"
self.assertEqual(puzzle.get_question_count(), 1)
def test_get_correct_answer_for_question(self):
"""测试 get_correct_answer_for_question 方法"""
puzzle = MCQPuzzle({}, [])
puzzle.answer = ["ans1", "ans2"]
self.assertEqual(puzzle.get_correct_answer_for_question(0), "ans1")
self.assertEqual(puzzle.get_correct_answer_for_question(1), "ans2")
self.assertIsNone(puzzle.get_correct_answer_for_question(2))
puzzle.answer = "not a list"
self.assertIsNone(puzzle.get_correct_answer_for_question(0))
def test_get_options_for_question(self):
"""测试 get_options_for_question 方法"""
puzzle = MCQPuzzle({}, [])
puzzle.options = [["a", "b", "c", "d"], ["e", "f", "g", "h"]]
self.assertEqual(puzzle.get_options_for_question(0), ["a", "b", "c", "d"])
self.assertEqual(puzzle.get_options_for_question(1), ["e", "f", "g", "h"])
self.assertIsNone(puzzle.get_options_for_question(2))
def test_str(self):
"""测试 __str__ 方法"""
puzzle = MCQPuzzle({}, [])
puzzle.wording = "选择题 - 尚未刷新谜题"
puzzle.answer = ["选择题 - 尚未刷新谜题"]
self.assertIn("选择题 - 尚未刷新谜题", str(puzzle))
self.assertIn("正确答案", str(puzzle))
puzzle.wording = ["Q1", "Q2"]
puzzle.answer = ["A1", "A2"]
str_repr = str(puzzle)
self.assertIn("Q1", str_repr)
self.assertIn("A1, A2", str_repr)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,114 +0,0 @@
import unittest
from unittest.mock import MagicMock, Mock, patch
from heurams.kernel.particles.atom import Atom
from heurams.kernel.particles.electron import Electron
from heurams.kernel.reactor.procession import Phaser
from heurams.kernel.reactor.states import PhaserState, ProcessionState
class TestPhaser(unittest.TestCase):
"""测试 Phaser 类"""
def setUp(self):
# 创建模拟的 Atom 对象
self.atom_new = Mock(spec=Atom)
self.atom_new.registry = {"electron": Mock(spec=Electron)}
self.atom_new.registry["electron"].is_activated.return_value = False
self.atom_old = Mock(spec=Atom)
self.atom_old.registry = {"electron": Mock(spec=Electron)}
self.atom_old.registry["electron"].is_activated.return_value = True
# 模拟 Procession 类以避免复杂依赖
self.procession_patcher = patch("heurams.kernel.reactor.phaser.Procession")
self.mock_procession_class = self.procession_patcher.start()
def tearDown(self):
self.procession_patcher.stop()
def test_init_with_mixed_atoms(self):
"""测试混合新旧原子的初始化"""
atoms = [self.atom_old, self.atom_new, self.atom_old]
phaser = Phaser(atoms)
# 应该创建两个 Procession: 一个用于旧原子, 一个用于新原子, 以及一个总体复习
self.assertEqual(self.mock_procession_class.call_count, 3)
# 检查调用参数
calls = self.mock_procession_class.call_args_list
# 第一个调用应该是旧原子的初始复习
self.assertEqual(calls[0][0][0], [self.atom_old, self.atom_old])
self.assertEqual(calls[0][0][1], PhaserState.QUICK_REVIEW)
# 第二个调用应该是新原子的识别阶段
self.assertEqual(calls[1][0][0], [self.atom_new])
self.assertEqual(calls[1][0][1], PhaserState.RECOGNITION)
# 第三个调用应该是所有原子的总体复习
self.assertEqual(calls[2][0][0], atoms)
self.assertEqual(calls[2][0][1], PhaserState.FINAL_REVIEW)
def test_init_only_old_atoms(self):
"""测试只有旧原子"""
atoms = [self.atom_old, self.atom_old]
phaser = Phaser(atoms)
# 应该创建两个 Procession: 一个初始复习, 一个总体复习
self.assertEqual(self.mock_procession_class.call_count, 2)
calls = self.mock_procession_class.call_args_list
self.assertEqual(calls[0][0][0], atoms)
self.assertEqual(calls[0][0][1], PhaserState.QUICK_REVIEW)
self.assertEqual(calls[1][0][0], atoms)
self.assertEqual(calls[1][0][1], PhaserState.FINAL_REVIEW)
def test_init_only_new_atoms(self):
"""测试只有新原子"""
atoms = [self.atom_new, self.atom_new]
phaser = Phaser(atoms)
self.assertEqual(self.mock_procession_class.call_count, 2)
calls = self.mock_procession_class.call_args_list
self.assertEqual(calls[0][0][0], atoms)
self.assertEqual(calls[0][0][1], PhaserState.RECOGNITION)
self.assertEqual(calls[1][0][0], atoms)
self.assertEqual(calls[1][0][1], PhaserState.FINAL_REVIEW)
def test_current_procession_finds_unfinished(self):
"""测试 current_procession 找到未完成的 Procession"""
# 创建模拟 Procession 实例
mock_proc1 = Mock()
mock_proc1.state = ProcessionState.FINISHED
mock_proc2 = Mock()
mock_proc2.state = ProcessionState.RUNNING
mock_proc2.phase = PhaserState.QUICK_REVIEW
phaser = Phaser([])
phaser.processions = [mock_proc1, mock_proc2]
result = phaser.current_procession()
self.assertEqual(result, mock_proc2)
self.assertEqual(phaser.state, PhaserState.QUICK_REVIEW)
def test_current_procession_all_finished(self):
"""测试所有 Procession 都完成"""
mock_proc = Mock()
mock_proc.state = ProcessionState.FINISHED
phaser = Phaser([])
phaser.processions = [mock_proc]
result = phaser.current_procession()
self.assertEqual(result, 0)
self.assertEqual(phaser.state, PhaserState.FINISHED)
def test_current_procession_empty(self):
"""测试没有 Procession"""
phaser = Phaser([])
phaser.processions = []
result = phaser.current_procession()
self.assertEqual(result, 0)
self.assertEqual(phaser.state, PhaserState.FINISHED)
if __name__ == "__main__":
unittest.main()

1082
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff