refactor: 完成 0.4.0 版本更新

完成 0.4.0 版本更新, 为了消除此前提交消息风格不一致与错误提交超大文件的问题, 维持代码统计数据的准确性和提交消息风格的一致性, 重新初始化仓库; 旧的提交历史在 HeurAMS-legacy 仓库(https://gitea.imwangzhiyu.xyz/ajax/HeurAMS-legacy)
This commit is contained in:
2025-12-17 22:31:38 +08:00
commit 2f23cfe174
89 changed files with 6112 additions and 0 deletions

7
src/heurams/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
print("欢迎使用 HeurAMS 及其组件!")
# 补充日志记录
from heurams.services.logger import get_logger
logger = get_logger(__name__)
logger.info("欢迎使用 HeurAMS 及其组件!")

7
src/heurams/__main__.py Normal file
View File

@@ -0,0 +1,7 @@
prompt = """HeurAMS 已经被成功地安装在系统中.
但 HeurAMS 被设计为一个带有辅助记忆调度器功能的软件包, 无法直接被执行, 但可被其他 Python 程序调用.
若您想启动内置的基本用户界面,
请运行 python -m heurams.interface,
或者 python -m heurams.interface.__main__
注意: 一个常见的误区是, 执行 interface 下的 __main__.py 运行基本用户界面, 这会导致 Python 上下文环境异常, 请不要这样做."""
print(prompt)

58
src/heurams/context.py Normal file
View File

@@ -0,0 +1,58 @@
"""
全局上下文管理模块
以及基准路径
"""
from contextvars import ContextVar
import pathlib
from heurams.services.config import ConfigFile
from heurams.services.logger import get_logger
# 默认配置文件路径规定: 以包目录为准
# 用户配置文件路径规定: 以运行目录为准
# 数据文件路径规定: 以运行目录为准
rootdir = pathlib.Path(__file__).parent
print(f"rootdir: {rootdir}")
logger = get_logger(__name__)
logger.debug(f"项目根目录: {rootdir}")
workdir = pathlib.Path.cwd()
print(f"workdir: {workdir}")
logger.debug(f"工作目录: {workdir}")
config_var: ContextVar[ConfigFile] = ContextVar(
"config_var", default=ConfigFile(rootdir / "default" / "config" / "config.toml")
)
try:
config_var: ContextVar[ConfigFile] = ContextVar(
"config_var", default=ConfigFile(workdir / "config" / "config.toml")
) # 配置文件
print("已加载自定义用户配置")
logger.info("已加载自定义用户配置, 路径: %s", workdir / "config" / "config.toml")
except Exception as e:
print("未能加载自定义用户配置")
logger.warning("未能加载自定义用户配置, 错误: %s", e)
# runtime_var: ContextVar = ContextVar('runtime_var', default=dict()) # 运行时共享数据
class ConfigContext:
"""
功能完备的上下文管理器
用于临时切换配置的作用域, 支持嵌套使用
Example:
>>> with ConfigContext(test_config):
... get_daystamp() # 使用 test_config
>>> get_daystamp() # 恢复原配置
"""
def __init__(self, config_provider: ConfigFile):
self.config_provider = config_provider
self._token = None
def __enter__(self):
self._token = config_var.set(self.config_provider)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
config_var.reset(self._token) # type: ignore

View File

@@ -0,0 +1,29 @@
# [调试] 将更改保存到文件
persist_to_file = 1
# [调试] 覆写时间, 设为 -1 以禁用
daystamp_override = -1
timestamp_override = -1
# [调试] 一键通过
quick_pass = 0
# 对于每个项目的默认新记忆原子数量
tasked_number = 8
# UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒
timezone_offset = +28800 # 中国标准时间 (UTC+8)
[puzzles] # 谜题默认配置
[puzzles.mcq]
max_riddles_num = 2
[puzzles.cloze]
min_denominator = 3
[paths] # 相对于工作目录而言 或绝对路径
nucleon_dir = "./data/nucleon"
electron_dir = "./data/electron"
orbital_dir = "./data/orbital"
cache_dir = "./data/cache"

View File

@@ -0,0 +1,2 @@
# Interface - 用户界面
与界面系统**强绑定**的相关代码文件, "界面系统" 在此处是基本界面实现相关的 Textual 框架

View File

@@ -0,0 +1,87 @@
from textual.app import App
from textual.widgets import Button
from .screens.dashboard import DashboardScreen
from .screens.nucreator import NucleonCreatorScreen
from .screens.precache import PrecachingScreen
from .screens.about import AboutScreen
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class HeurAMSApp(App):
TITLE = "潜进"
CSS_PATH = "css/main.tcss"
SUB_TITLE = "启发式辅助记忆调度器"
BINDINGS = [
("q", "quit", "退出"),
("d", "toggle_dark", "切换色调"),
("1", "app.push_screen('dashboard')", "仪表盘"),
("2", "app.push_screen('precache_all')", "缓存管理器"),
("3", "app.push_screen('nucleon_creator')", "创建新单元"),
("0", "app.push_screen('about')", "版本信息"),
]
SCREENS = {
"dashboard": DashboardScreen,
"nucleon_creator": NucleonCreatorScreen,
"precache_all": PrecachingScreen,
"about": AboutScreen,
}
def on_mount(self) -> None:
self.push_screen("dashboard")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.exit(event.button.id)
def action_do_nothing(self):
print("DO NOTHING")
self.refresh()
def environment_check():
from pathlib import Path
logger.debug("检查环境路径")
for i in config_var.get()["paths"].values():
i = Path(i)
if not i.exists():
logger.info("创建目录: %s", i)
print(f"创建 {i}")
i.mkdir(exist_ok=True, parents=True)
else:
logger.debug("目录已存在: %s", i)
print(f"找到 {i}")
logger.debug("环境检查完成")
def is_subdir(parent, child):
try:
child.relative_to(parent)
logger.debug("is_subdir: %s%s 的子目录", child, parent)
return 1
except:
logger.debug("is_subdir: %s 不是 %s 的子目录", child, parent)
return 0
# 开发模式
from heurams.context import rootdir, workdir, config_var
from pathlib import Path
from heurams.context import rootdir
import os
if is_subdir(Path(rootdir), Path(os.getcwd())):
os.chdir(Path(rootdir) / ".." / "..")
print(f'转入开发数据目录: {Path(rootdir)/".."/".."}')
environment_check()
app = HeurAMSApp()
if __name__ == "__main__":
app.run()
def main():
app.run()

View File

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Static,
Button,
Markdown,
)
from textual.containers import ScrollableContainer, ScrollableContainer
from textual.screen import Screen
import heurams.services.version as version
from heurams.context import *
class AboutScreen(Screen):
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer(id="about_container"):
yield Label("[b]关于与版本信息[/b]")
about_text = f"""
# 关于 "潜进"
版本 {version.ver} {version.stage.capitalize()}
开发代号: {version.codename.capitalize()}
一个基于启发式算法的开放源代码记忆调度器, 旨在帮助用户更高效地进行记忆工作与学习规划.
以 AGPL-3.0 开放源代码
开发人员:
- Wang Zhiyu([@pluvium27](https://github.com/pluvium27)): 项目作者
特别感谢:
- [Piotr A. Woźniak](https://supermemo.guru/wiki/Piotr_Wozniak): SuperMemo-2 算法
- [Thoughts Memo](https://www.zhihu.com/people/L.M.Sherlock): 文献参考
# 参与贡献
我们是一个年轻且包容的社区, 由技术人员, 设计师, 文书工作者, 以及创意人员共同构成,
通过我们协力开发的软件为所有人谋取福祉.
上述工作不可避免地让我们确立了下列价值观 (取自 KDE 宣言):
- 开放治理 确保更多人能参与我们的领导和决策进程;
- 自由软件 确保我们的工作成果随时能为所有人所用;
- 多样包容 确保所有人都能加入社区并参加工作;
- 创新精神 确保新思路能不断涌现并服务于所有人;
- 共同产权 确保我们能团结一致;
- 迎合用户 确保我们的成果对所有人有用.
综上所述, 在为我们共同目标奋斗的过程中, 我们认为上述价值观反映了我们社区的本质, 是我们始终如一地保持初心的关键所在.
这是一项立足于协作精神的事业, 它的运作和产出不受任何单一个人或者机构的操纵.
我们的共同目标是为人人带来高品质的辅助记忆 & 学习软件.
不管您来自何方, 我们都欢迎您加入社区并做出贡献.
"""
# """
# 学术数据
# "潜进" 的用户数据可用于科学方面的研究, 我们将在未来版本添加学术数据的收集和展示平台
# """
yield Markdown(about_text, classes="about-markdown")
yield Button(
"返回主界面",
id="back_button",
variant="primary",
classes="back-button",
)
yield Footer()
def action_go_back(self):
self.app.pop_screen()
def on_button_pressed(self, event) -> None:
event.stop()
if event.button.id == "back_button":
self.action_go_back()

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
ListView,
ListItem,
Button,
Static,
)
from textual.containers import ScrollableContainer
from textual.screen import Screen
from heurams.kernel.particles import *
from heurams.context import *
import heurams.services.version as version
import heurams.services.timer as timer
from .preparation import PreparationScreen
from .about import AboutScreen
from heurams.services.logger import get_logger
import pathlib
logger = get_logger(__name__)
class DashboardScreen(Screen):
SUB_TITLE = "仪表盘"
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield ScrollableContainer(
Label(f'欢迎使用 "潜进" 启发式辅助记忆调度器', classes="title-label"),
Label(f"当前 UNIX 日时间戳: {timer.get_daystamp()}"),
Label(f'时区修正: UTC+{config_var.get()["timezone_offset"] / 3600}'),
Label("选择待学习或待修改的记忆单元集:", classes="title-label"),
ListView(id="union-list", classes="union-list-view"),
Label(
f'"潜进" 启发式辅助记忆调度器 | 版本 {version.ver} {version.codename.capitalize()} 2025'
),
)
yield Footer()
def item_desc_generator(self, filename) -> dict:
"""简单分析以生成项目项显示文本
Returns:
dict: 以数字为列表, 分别呈现单行字符串
"""
res = dict()
filestem = pathlib.Path(filename).stem
res[0] = f"{filename}\0"
from heurams.kernel.particles.loader import load_electron
import heurams.kernel.particles as pt
electron_file_path = pathlib.Path(config_var.get()["paths"]["electron_dir"]) / (
filestem + ".json"
)
logger.debug(f"电子文件路径: {electron_file_path}")
if electron_file_path.exists(): # 未找到则创建电子文件 (json)
pass
else:
electron_file_path.touch()
with open(electron_file_path, "w") as f:
f.write("{}")
electron_dict = load_electron(path=electron_file_path) # TODO: 取消硬编码扩展名
logger.debug(electron_dict)
is_due = 0
is_activated = 0
nextdate = 0x3F3F3F3F
for i in electron_dict.values():
i: pt.Electron
logger.debug(i, i.is_due())
if i.is_due():
is_due = 1
if i.is_activated():
is_activated = 1
nextdate = min(nextdate, i.nextdate())
res[1] = f"下一次复习: {nextdate}\n"
res[1] += f"{is_due if "需要复习" else "当前无需复习"}"
if not is_activated:
res[1] = " 尚未激活"
return res
def on_mount(self) -> None:
union_list_widget = self.query_one("#union-list", ListView)
probe = probe_all(0)
if len(probe["nucleon"]):
for file in probe["nucleon"]:
text = self.item_desc_generator(file)
union_list_widget.append(
ListItem(
Label(text[0] + "\n" + text[1]),
)
)
else:
union_list_widget.append(
ListItem(
Static(
"在 ./nucleon/ 中未找到任何内容源数据文件.\n请放置文件后重启应用.\n或者新建空的单元集."
)
)
)
union_list_widget.disabled = True
def on_list_view_selected(self, event) -> None:
if not isinstance(event.item, ListItem):
return
selected_label = event.item.query_one(Label)
if "未找到任何 .toml 文件" in str(selected_label.renderable): # type: ignore
return
selected_filename = pathlib.Path(
str(selected_label.renderable)
.partition("\0")[0] # 文件名末尾截断, 保留文件名
.replace("*", "")
) # 去除markdown加粗
nucleon_file_path = (
pathlib.Path(config_var.get()["paths"]["nucleon_dir"]) / selected_filename
)
electron_file_path = pathlib.Path(config_var.get()["paths"]["electron_dir"]) / (
str(selected_filename.stem) + ".json"
)
self.app.push_screen(PreparationScreen(nucleon_file_path, electron_file_path))
def on_button_pressed(self, event) -> None:
if event.button.id == "new_nucleon_button":
# 切换到创建单元
from .nucreator import NucleonCreatorScreen
newscr = NucleonCreatorScreen()
self.app.push_screen(newscr)
elif event.button.id == "precache_all_button":
# 切换到缓存管理器
from .precache import PrecachingScreen
precache_screen = PrecachingScreen()
self.app.push_screen(precache_screen)
elif event.button.id == "about_button":
from .about import AboutScreen
about_screen = AboutScreen()
self.app.push_screen(about_screen)
def action_quit_app(self) -> None:
self.app.exit()

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import Header, Footer, Label, Static, Button
from textual.containers import Center, ScrollableContainer
from textual.screen import Screen
from textual.reactive import reactive
from enum import Enum, auto
from heurams.services.logger import get_logger
from heurams.context import config_var
from heurams.kernel.reactor import *
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from .. import shim
class AtomState(Enum):
FAILED = auto()
NORMAL = auto()
logger = get_logger(__name__)
class MemScreen(Screen):
BINDINGS = [
("q", "pop_screen", "返回"),
# ("p", "prev", "复习上一个"),
("d", "toggle_dark", ""),
("v", "play_voice", "朗读"),
("0,1,2,3", "app.push_screen('about')", ""),
]
if config_var.get()["quick_pass"]:
BINDINGS.append(("k", "quick_pass", "跳过"))
rating = reactive(-1)
def __init__(
self,
atoms: list,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
) -> None:
super().__init__(name, id, classes)
self.atoms = atoms
self.phaser = Phaser(atoms)
# logger.debug(self.phaser.state)
self.procession: Procession = self.phaser.current_procession() # type: ignore
self.atom: pt.Atom = self.procession.current_atom
# logger.debug(self.phaser.state)
# self.procession.forward(1)
for i in atoms:
i.do_eval()
def on_mount(self):
self.load_puzzle()
pass
def puzzle_widget(self):
try:
logger.debug(self.phaser.state)
logger.debug(self.procession.cursor)
logger.debug(self.atom)
self.fission = Fission(self.atom, self.phaser.state)
puzzle_debug = next(self.fission.generate())
# logger.debug(puzzle_debug)
return shim.puzzle2widget[puzzle_debug["puzzle"]](
atom=self.atom, alia=puzzle_debug["alia"]
)
except (KeyError, StopIteration, AttributeError) as e:
logger.debug(f"调度展开出错: {e}")
return Static("无法生成谜题")
# logger.debug(shim.puzzle2widget[puzzle_debug["puzzle"]])
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer():
yield Label(self._get_progress_text(), id="progress")
# self.mount(self.current_widget()) # type: ignore
yield ScrollableContainer(id="puzzle-container")
# yield Button("重新学习此单元", id="re-recognize", variant="warning")
yield Footer()
def _get_progress_text(self):
return f"当前进度: {self.procession.process() + 1}/{self.procession.total_length()}"
def update_display(self):
progress_widget = self.query_one("#progress")
progress_widget.update(self._get_progress_text()) # type: ignore
def load_puzzle(self):
self.atom: pt.Atom = self.procession.current_atom
container = self.query_one("#puzzle-container")
for i in container.children:
i.remove()
container.mount(self.puzzle_widget())
def load_finished_widget(self):
container = self.query_one("#puzzle-container")
for i in container.children:
i.remove()
from heurams.interface.widgets.finished import Finished
container.mount(Finished())
def on_button_pressed(self, event):
event.stop()
def watch_rating(self, old_rating, new_rating) -> None:
if self.procession == 0:
return
if new_rating == -1:
return
forwards = 1 if new_rating >= 4 else 0
self.rating = -1
logger.debug(f"试图前进: {"允许" if forwards else "禁止"}")
if forwards:
ret = self.procession.forward(1)
if ret == 0: # 若结束了此次队列
self.procession = self.phaser.current_procession() # type: ignore
if self.procession == 0: # 若所有队列都结束了
logger.debug(f"记忆进程结束")
for i in self.atoms:
i: pt.Atom
i.revise()
i.persist("electron")
self.load_finished_widget()
return
else:
logger.debug(f"建立新队列 {self.procession.phase}")
self.load_puzzle()
else: # 若不通过
self.procession.append()
self.update_display()
def action_quick_pass(self):
self.rating = 5
self.atom.minimize(5)
self.atom.registry["electron"].activate()
self.atom.lock(1)
def action_play_voice(self):
"""朗读当前内容"""
pass
def action_toggle_dark(self):
self.app.action_toggle_dark()
def action_pop_screen(self):
self.app.pop_screen()

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Input,
Select,
Button,
Markdown,
)
from textual.containers import ScrollableContainer
from textual.screen import Screen
from heurams.services.version import ver
import toml
from pathlib import Path
from heurams.context import config_var
class NucleonCreatorScreen(Screen):
BINDINGS = [("q", "go_back", "返回")]
SUB_TITLE = "单元集创建向导"
def __init__(self) -> None:
super().__init__(name=None, id=None, classes=None)
def search_templates(self):
from pathlib import Path
from heurams.context import config_var
template_dir = Path(config_var.get()["paths"]["template_dir"])
templates = list()
for i in template_dir.iterdir():
if i.name.endswith(".toml"):
try:
import toml
with open(i, "r") as f:
dic = toml.load(f)
desc = dic["__metadata__.attribution"]["desc"]
templates.append(desc + " (" + i.name + ")")
except Exception as e:
templates.append(f"无描述模板 ({i.name})")
print(e)
return templates
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer(id="vice_container"):
yield Label(f"[b]空白单元集创建向导\n")
yield Markdown(
"> 提示: 你可能注意到当选中文本框时底栏和操作按键绑定将被覆盖 \n只需选中(使用鼠标或 Tab)选择框即可恢复底栏功能"
)
yield Markdown("1. 键入单元集名称")
yield Input(placeholder="单元集名称", id="name_input")
yield Markdown(
"> 单元集名称不应与现有单元集重复. \n> 新的单元集文件将创建在 ./nucleon/你输入的名称.toml"
)
yield Label(f"\n")
yield Markdown("2. 选择单元集模板")
LINES = self.search_templates()
"""带有宏支持的空白单元集 ({ver})
古诗词模板单元集 ({ver})
英语词汇和短语模板单元集 ({ver})
"""
yield Select.from_values(LINES, prompt="选择类型", id="template_select")
yield Markdown("> 新单元集的版本号将和主程序版本保持同步")
yield Label(f"\n")
yield Markdown("3. 输入常见附加元数据 (可选)")
yield Input(placeholder="作者", id="author_input")
yield Input(placeholder="内容描述", id="desc_input")
yield Button(
"新建空白单元集",
id="submit_button",
variant="primary",
classes="start-button",
)
yield Footer()
def on_mount(self):
self.query_one("#submit_button").focus()
def action_go_back(self):
self.app.pop_screen()
def action_quit_app(self):
self.app.exit()
def on_button_pressed(self, event) -> None:
event.stop()
if event.button.id == "submit_button":
# 获取输入值
name_input = self.query_one("#name_input")
template_select = self.query_one("#template_select")
author_input = self.query_one("#author_input")
desc_input = self.query_one("#desc_input")
name = name_input.value.strip() # type: ignore
author = author_input.value.strip() # type: ignore
desc = desc_input.value.strip() # type: ignore
selected = template_select.value # type: ignore
# 验证
if not name:
self.notify("单元集名称不能为空", severity="error")
return
# 获取配置路径
config = config_var.get()
nucleon_dir = Path(config["paths"]["nucleon_dir"])
template_dir = Path(config["paths"]["template_dir"])
# 检查文件是否已存在
nucleon_path = nucleon_dir / f"{name}.toml"
if nucleon_path.exists():
self.notify(f"单元集 '{name}' 已存在", severity="error")
return
# 确定模板文件
if selected is None:
self.notify("请选择一个模板", severity="error")
return
# selected 是描述字符串, 格式如 "描述 (filename.toml)"
# 提取文件名
import re
match = re.search(r"\(([^)]+)\)$", selected)
if not match:
self.notify("模板选择格式无效", severity="error")
return
template_filename = match.group(1)
template_path = template_dir / template_filename
if not template_path.exists():
self.notify(f"模板文件不存在: {template_filename}", severity="error")
return
# 加载模板
try:
with open(template_path, "r", encoding="utf-8") as f:
template_data = toml.load(f)
except Exception as e:
self.notify(f"加载模板失败: {e}", severity="error")
return
# 更新元数据
metadata = template_data.get("__metadata__", {})
attribution = metadata.get("attribution", {})
if author:
attribution["author"] = author
if desc:
attribution["desc"] = desc
attribution["name"] = name
# 可选: 设置版本
attribution["version"] = ver
metadata["attribution"] = attribution
template_data["__metadata__"] = metadata
# 确保 nucleon_dir 存在
nucleon_dir.mkdir(parents=True, exist_ok=True)
# 写入新文件
try:
with open(nucleon_path, "w", encoding="utf-8") as f:
toml.dump(template_data, f)
except Exception as e:
self.notify(f"保存单元集失败: {e}", severity="error")
return
self.notify(f"单元集 '{name}' 创建成功")
self.app.pop_screen()

View File

@@ -0,0 +1,246 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Button,
Static,
ProgressBar,
)
from textual.containers import ScrollableContainer, Horizontal
from textual.containers import ScrollableContainer
from textual.screen import Screen
import pathlib
import heurams.kernel.particles as pt
import heurams.services.hasher as hasher
from heurams.context import *
from textual.worker import get_current_worker
class PrecachingScreen(Screen):
"""预缓存音频文件屏幕
缓存记忆单元音频文件, 全部(默认) 或部分记忆单元(可选参数传入)
Args:
nucleons (list): 可选列表, 仅包含 Nucleon 对象
desc (list): 可选字符串, 包含对此次调用的文字描述
"""
SUB_TITLE = "缓存管理器"
BINDINGS = [("q", "go_back", "返回")]
def __init__(self, nucleons: list = [], desc: str = ""):
super().__init__(name=None, id=None, classes=None)
self.nucleons = nucleons
self.is_precaching = False
self.current_file = ""
self.current_item = ""
self.progress = 0
self.total = len(nucleons)
self.processed = 0
self.precache_worker = None
self.cancel_flag = 0
self.desc = desc
for i in nucleons:
i: pt.Nucleon
i.do_eval()
# print("完成 EVAL")
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer(id="precache_container"):
yield Label("[b]音频预缓存[/b]", classes="title-label")
if self.nucleons:
yield Static(f"目标单元归属: [b]{self.desc}[/b]", classes="target-info")
yield Static(f"单元数量: {len(self.nucleons)}", classes="target-info")
else:
yield Static("目标: 所有单元", classes="target-info")
yield Static(id="status", classes="status-info")
yield Static(id="current_item", classes="current-item")
yield ProgressBar(total=100, show_eta=False, id="progress_bar")
with Horizontal(classes="button-group"):
if not self.is_precaching:
yield Button("开始预缓存", id="start_precache", variant="primary")
else:
yield Button("取消预缓存", id="cancel_precache", variant="error")
yield Button("清空缓存", id="clear_cache", variant="warning")
yield Button("返回", id="go_back", variant="default")
yield Static("若您离开此界面, 未完成的缓存进程会自动停止.")
yield Static('缓存程序支持 "断点续传".')
yield Footer()
def on_mount(self):
"""挂载时初始化状态"""
self.update_status("就绪", "等待开始...")
def update_status(self, status, current_item="", progress=None):
"""更新状态显示"""
status_widget = self.query_one("#status", Static)
item_widget = self.query_one("#current_item", Static)
progress_bar = self.query_one("#progress_bar", ProgressBar)
status_widget.update(f"状态: {status}")
item_widget.update(f"当前项目: {current_item}" if current_item else "")
if progress is not None:
progress_bar.progress = progress
progress_bar.advance(0) # 刷新显示
def precache_by_text(self, text: str):
"""预缓存单段文本的音频"""
from heurams.context import rootdir, workdir, config_var
cache_dir = pathlib.Path(config_var.get()["paths"]["cache_dir"])
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / f"{hasher.get_md5(text)}.wav"
if not cache_file.exists():
try: # TODO: 调用模块消除tts耦合
import edge_tts as tts
communicate = tts.Communicate(text, "zh-CN-XiaoxiaoNeural")
communicate.save_sync(str(cache_file))
return 1
except Exception as e:
print(f"预缓存失败 '{text}': {e}")
return 0
return 1
def precache_by_nucleon(self, nucleon: pt.Nucleon):
"""依据 Nucleon 缓存"""
# print(nucleon.metadata['formation']['tts_text'])
ret = self.precache_by_text(nucleon.metadata["formation"]["tts_text"])
return ret
# print(f"TTS 缓存: {nucleon.metadata['formation']['tts_text']}")
def precache_by_list(self, nucleons: list):
"""依据 Nucleons 列表缓存"""
for idx, nucleon in enumerate(nucleons):
# print(f"PROC: {nucleon}")
worker = get_current_worker()
if worker and worker.is_cancelled: # 函数在worker中执行且已被取消
return False
text = nucleon.metadata["formation"]["tts_text"]
# self.current_item = text[:30] + "..." if len(text) > 50 else text
# print(text)
self.processed += 1
# print(self.processed)
# print(self.total)
progress = int((self.processed / self.total) * 100) if self.total > 0 else 0
# print(progress)
self.update_status(f"正处理 ({idx + 1}/{len(nucleons)})", text, progress)
ret = self.precache_by_nucleon(nucleon)
if not ret:
self.update_status(
"出错",
f"处理失败, 跳过: {self.current_item}",
)
import time
time.sleep(1)
if self.cancel_flag:
worker.cancel()
self.cancel_flag = 0
return False
return True
def precache_by_nucleons(self):
# print("开始缓存")
ret = self.precache_by_list(self.nucleons)
# print(f"返回 {ret}")
return ret
def precache_by_filepath(self, path: pathlib.Path):
"""预缓存单个文件的所有内容"""
lst = list()
for i in pt.load_nucleon(path):
lst.append(i[0])
return self.precache_by_list(lst)
def precache_all_files(self):
"""预缓存所有文件"""
from heurams.context import rootdir, workdir, config_var
nucleon_path = pathlib.Path(config_var.get()["paths"]["nucleon_dir"])
nucleon_files = [
f for f in nucleon_path.iterdir() if f.suffix == ".toml"
] # TODO: 解耦合
# 计算总项目数
self.total = 0
nu = list()
for file in nucleon_files:
try:
for i in pt.load_nucleon(file):
nu.append(i[0])
except:
continue
self.total = len(nu)
for i in nu:
i: pt.Nucleon
i.do_eval()
return self.precache_by_list(nu)
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
if event.button.id == "start_precache" and not self.is_precaching:
# 开始预缓存
if self.nucleons:
self.precache_worker = self.run_worker(
self.precache_by_nucleons,
thread=True,
exclusive=True,
exit_on_error=True,
)
else:
self.precache_worker = self.run_worker(
self.precache_all_files,
thread=True,
exclusive=True,
exit_on_error=True,
)
elif event.button.id == "cancel_precache" and self.is_precaching:
# 取消预缓存
if self.precache_worker:
self.precache_worker.cancel()
self.is_precaching = False
self.processed = 0
self.progress = 0
self.update_status("已取消", "预缓存操作被用户取消", 0)
elif event.button.id == "clear_cache":
# 清空缓存
try:
import shutil
from heurams.context import rootdir, workdir, config_var
shutil.rmtree(
f"{config_var.get()["paths"]["cache_dir"]}", ignore_errors=True
)
self.update_status("已清空", "音频缓存已清空", 0)
except Exception as e:
self.update_status("错误", f"清空缓存失败: {e}")
self.cancel_flag = 1
self.processed = 0
self.progress = 0
elif event.button.id == "go_back":
self.action_go_back()
def action_go_back(self):
if self.is_precaching and self.precache_worker:
self.precache_worker.cancel()
self.app.pop_screen()
def action_quit_app(self):
if self.is_precaching and self.precache_worker:
self.precache_worker.cancel()
self.app.exit()

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Static,
Button,
Markdown,
)
from textual.containers import ScrollableContainer
from textual.screen import Screen
from heurams.context import config_var
import heurams.kernel.particles as pt
import heurams.services.hasher as hasher
from heurams.context import *
from textual.reactive import reactive
from textual.widget import Widget
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class PreparationScreen(Screen):
SUB_TITLE = "准备记忆集"
BINDINGS = [
("q", "go_back", "返回"),
("p", "precache", "预缓存音频"),
("d", "toggle_dark", ""),
("0,1,2,3", "app.push_screen('about')", ""),
]
scheduled_num = reactive(config_var.get()["scheduled_num"])
def __init__(self, nucleon_file: pathlib.Path, electron_file: pathlib.Path) -> None:
super().__init__(name=None, id=None, classes=None)
self.nucleon_file = nucleon_file
self.electron_file = electron_file
self.nucleons_with_orbital = pt.load_nucleon(self.nucleon_file)
self.electrons = pt.load_electron(self.electron_file)
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer(id="vice_container"):
yield Label(f"准备就绪: [b]{self.nucleon_file.stem}[/b]\n")
yield Label(
f"内容源文件: {config_var.get()['paths']['nucleon_dir']}/[b]{self.nucleon_file.name}[/b]"
)
yield Label(
f"元数据文件: {config_var.get()['paths']['electron_dir']}/[b]{self.electron_file.name}[/b]"
)
yield Label(f"\n单元数量: {len(self.nucleons_with_orbital)}\n")
yield Label(f"单次记忆数量: {self.scheduled_num}", id="schnum_label")
yield Button(
"开始记忆",
id="start_memorizing_button",
variant="primary",
classes="start-button",
)
yield Button(
"预缓存音频",
id="precache_button",
variant="success",
classes="precache-button",
)
yield Static(f"\n单元预览:\n")
yield Markdown(self._get_full_content().replace("/", ""), classes="full")
yield Footer()
# def watch_scheduled_num(self, old_scheduled_num, new_scheduled_num):
# logger.debug("响应", old_scheduled_num, "->", new_scheduled_num)
# try:
# one = self.query_one("#schnum_label")
# one.update(f"单次记忆数量: {new_scheduled_num}") # type: ignore
# except:
# pass
def _get_full_content(self):
content = ""
for nucleon, orbital in self.nucleons_with_orbital:
nucleon: pt.Nucleon
# print(nucleon.payload)
content += " - " + nucleon["content"] + " \n"
return content
def action_go_back(self):
self.app.pop_screen()
def action_precache(self):
from ..screens.precache import PrecachingScreen
lst = list()
for i in self.nucleons_with_orbital:
lst.append(i[0])
precache_screen = PrecachingScreen(lst)
self.app.push_screen(precache_screen)
def action_quit_app(self):
self.app.exit()
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
logger.debug("按下按钮")
if event.button.id == "start_memorizing_button":
atoms = list()
for nucleon, orbital in self.nucleons_with_orbital:
atom = pt.Atom(nucleon.ident)
atom.link("nucleon", nucleon)
try:
atom.link("electron", self.electrons[nucleon.ident])
except KeyError:
atom.link("electron", pt.Electron(nucleon.ident))
atom.link("orbital", orbital)
atom.link("nucleon_fmt", "toml")
atom.link("electron_fmt", "json")
atom.link("orbital_fmt", "toml")
atom.link("nucleon_path", self.nucleon_file)
atom.link("electron_path", self.electron_file)
atom.link("orbital_path", None)
atoms.append(atom)
atoms_to_provide = list()
left_new = self.scheduled_num
for i in atoms:
i: pt.Atom
if i.registry["electron"].is_due():
atoms_to_provide.append(i)
else:
if i.registry["electron"].is_activated():
pass
else:
left_new -= 1
if left_new >= 0:
atoms_to_provide.append(i)
logger.debug(f"ATP: {atoms_to_provide}")
from .memorizor import MemScreen
memscreen = MemScreen(atoms_to_provide)
self.app.push_screen(memscreen)
elif event.button.id == "precache_button":
self.action_precache()

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
from textual.app import ComposeResult
from textual.widgets import (
Header,
Footer,
Label,
Button,
Static,
ProgressBar,
)
from textual.containers import ScrollableContainer, Horizontal
from textual.containers import ScrollableContainer
from textual.screen import Screen
import pathlib
import heurams.kernel.particles as pt
import heurams.services.hasher as hasher
from heurams.context import *
from textual.worker import get_current_worker
class SyncScreen(Screen):
BINDINGS = [("q", "go_back", "返回")]
def __init__(self, nucleons: list = [], desc: str = ""):
super().__init__(name=None, id=None, classes=None)
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with ScrollableContainer(id="sync_container"):
pass
yield Footer()
def on_mount(self):
"""挂载时初始化状态"""
def update_status(self, status, current_item="", progress=None):
"""更新状态显示"""
def on_button_pressed(self, event: Button.Pressed) -> None:
event.stop()
def action_go_back(self):
self.app.pop_screen()
def action_quit_app(self):
self.app.exit()

View File

View File

@@ -0,0 +1,36 @@
"""Kernel 操作辅助函数库"""
import random
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
import heurams.interface.widgets as pzw
from typing import TypedDict
staging = {} # 细粒度缓存区, 是 ident -> quality 的封装
def report_to_staging(atom: pt.Atom, quality):
staging[atom.ident] = min(quality, staging[atom.ident])
def clear():
staging = dict()
def deploy_to_electron():
for atom_ident, quality in staging.items():
if pt.atom_registry[atom_ident].registry["electron"].is_activated:
pt.atom_registry[atom_ident].registry["electron"].revisor(quality=quality)
else:
pt.atom_registry[atom_ident].registry["electron"].revisor(
quality=quality, is_new_activation=True
)
clear()
puzzle2widget = {
pz.RecognitionPuzzle: pzw.Recognition,
pz.ClozePuzzle: pzw.ClozePuzzle,
pz.MCQPuzzle: pzw.MCQPuzzle,
pz.BasePuzzle: pzw.BasePuzzleWidget,
}

View File

@@ -0,0 +1,7 @@
from .base_puzzle_widget import BasePuzzleWidget
from .basic_puzzle import BasicEvaluation
from .cloze_puzzle import ClozePuzzle
from .finished import Finished
from .mcq_puzzle import MCQPuzzle
from .placeholder import Placeholder
from .recognition import Recognition

View File

@@ -0,0 +1,32 @@
from typing import Iterable
from textual.app import ComposeResult
from textual.widget import Widget
import heurams.kernel.particles as pt
class BasePuzzleWidget(Widget):
def __init__(
self,
*children: Widget,
atom: pt.Atom,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True
) -> None:
super().__init__(
*children,
name=name,
id=id,
classes=classes,
disabled=disabled,
markup=markup
)
self.atom = atom
def compose(self) -> Iterable[Widget]:
return super().compose()
def handler(self, rating) -> None:
pass

View File

@@ -0,0 +1,119 @@
from textual.widgets import (
Label,
Static,
Button,
)
from textual.containers import ScrollableContainer, Horizontal
from textual.widget import Widget
import heurams.kernel.particles as pt
from .base_puzzle_widget import BasePuzzleWidget
from textual.message import Message
class BasicEvaluation(BasePuzzleWidget):
def __init__(
self,
*children: Widget,
atom: pt.Atom,
alia: str = "",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True,
) -> None:
super().__init__(
*children,
atom=atom,
name=name,
id=id,
classes=classes,
disabled=disabled,
markup=markup,
)
class RatingChanged(Message):
def __init__(self, rating: int) -> None:
self.rating = rating # 评分值 (0-5)
super().__init__()
# 反馈映射表
feedback_mapping = {
"feedback_5": {"rating": 5, "text": "完美回想"},
"feedback_4": {"rating": 4, "text": "犹豫后正确"},
"feedback_3": {"rating": 3, "text": "困难地正确"},
"feedback_2": {"rating": 2, "text": "错误但熟悉"},
"feedback_1": {"rating": 1, "text": "错误且不熟"},
"feedback_0": {"rating": 0, "text": "完全空白"},
}
def compose(self):
# 显示主要内容
yield Label(self.atom.registry["nucleon"]["content"], id="main")
# 显示评估说明(可选)
yield Static("请评估你对这个内容的记忆程度: ", classes="instruction")
# 按钮容器
with ScrollableContainer(id="button_container"):
btn = {}
btn["5"] = Button(
"完美回想", variant="success", id="feedback_5", classes="choice"
)
btn["4"] = Button(
"犹豫后正确", variant="success", id="feedback_4", classes="choice"
)
btn["3"] = Button(
"困难地正确", variant="warning", id="feedback_3", classes="choice"
)
btn["2"] = Button(
"错误但熟悉", variant="warning", id="feedback_2", classes="choice"
)
btn["1"] = Button(
"错误且不熟", variant="error", id="feedback_1", classes="choice"
)
btn["0"] = Button(
"完全空白", variant="error", id="feedback_0", classes="choice"
)
# 布局按钮
yield Horizontal(btn["5"], btn["4"])
yield Horizontal(btn["3"], btn["2"])
yield Horizontal(btn["1"], btn["0"])
def on_button_pressed(self, event: Button.Pressed) -> None:
"""处理按钮点击事件"""
button_id = event.button.id
if button_id in self.feedback_mapping:
feedback_info = self.feedback_mapping[button_id]
self.post_message(
self.RatingChanged(
rating=feedback_info["rating"],
)
)
event.button.add_class("selected")
self.disable_other_buttons(button_id)
def disable_other_buttons(self, selected_button_id: str) -> None:
for button in self.query("Button.choice"):
if button.id != selected_button_id:
button.disabled = True
def enable_all_buttons(self) -> None:
for button in self.query("Button.choice"):
button.disabled = False
def on_key(self, event) -> None:
if event.key in ["0", "1", "2", "3", "4", "5"]:
button_id = f"feedback_{event.key}"
if button_id in self.feedback_mapping:
# 模拟按钮点击
self.post_message(
self.RatingChanged(
rating=self.feedback_mapping[button_id]["rating"],
)
)

View File

@@ -0,0 +1,109 @@
from textual.widgets import (
Label,
Button,
)
from textual.widget import Widget
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from .base_puzzle_widget import BasePuzzleWidget
import copy
import random
from textual.containers import Container
from textual.message import Message
from heurams.services.logger import get_logger
from typing import TypedDict
logger = get_logger(__name__)
class Setting(TypedDict):
__origin__: str
__hint__: str
text: str
delimiter: str
min_denominator: str
class ClozePuzzle(BasePuzzleWidget):
def __init__(
self,
*children: Widget,
atom: pt.Atom,
alia: str = "",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True,
) -> None:
super().__init__(
*children,
atom=atom,
name=name,
id=id,
classes=classes,
disabled=disabled,
markup=markup,
)
self.inputlist = list()
self.hashtable = {}
self.alia = alia
self._load()
self.hashmap = dict()
def _load(self):
setting = self.atom.registry["orbital"]["puzzles"][self.alia]
self.puzzle = pz.ClozePuzzle(
text=setting["text"],
delimiter=setting["delimiter"],
min_denominator=int(setting["min_denominator"]),
)
self.puzzle.refresh()
self.ans = copy.copy(self.puzzle.answer) # 乱序
random.shuffle(self.ans)
def compose(self):
yield Label(self.puzzle.wording, id="sentence")
yield Label(f"当前输入: {self.inputlist}", id="inputpreview")
# 渲染当前问题的选项
with Container(id="btn-container"):
for i in self.ans:
self.hashmap[str(hash(i))] = i
btnid = f"sel000-{hash(i)}"
logger.debug(f"建立按钮 {btnid}")
yield Button(i, id=f"{btnid}")
yield Button("退格", id="delete")
def update_display(self):
preview = self.query_one("#inputpreview")
preview.update(f"当前输入: {self.inputlist}") # type: ignore
def on_button_pressed(self, event: Button.Pressed) -> None:
button_id = event.button.id
if button_id == "delete":
if len(self.inputlist) > 0:
self.inputlist.pop()
self.update_display()
else:
answer_text = self.hashmap[button_id[7:]] # type: ignore
self.inputlist.append(answer_text)
self.update_display()
if len(self.inputlist) >= len(self.puzzle.answer):
is_correct = self.inputlist == self.puzzle.answer
rating = 4 if is_correct else 2
self.handler(rating)
self.screen.rating = rating # type: ignore
if not is_correct:
self.inputlist = []
self.update_display()
def handler(self, rating):
if self.atom.lock():
pass
else:
self.atom.minimize(rating)

View File

@@ -0,0 +1,36 @@
from textual.widgets import (
Label,
Button,
)
from textual.widget import Widget
class Finished(Widget):
def __init__(
self,
*children: Widget,
alia="",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True
) -> None:
self.alia = alia
super().__init__(
*children,
name=name,
id=id,
classes=classes,
disabled=disabled,
markup=markup
)
def compose(self):
yield Label("本次记忆进程结束", id="finished_msg")
yield Button("返回上一级", id="back-to-menu")
def on_button_pressed(self, event):
button_id = event.button.id
if button_id == "back-to-menu":
self.app.pop_screen()

View File

@@ -0,0 +1,160 @@
# 单项选择题
from textual.widgets import (
Label,
Button,
)
from textual.containers import ScrollableContainer, Container
from textual.widget import Widget
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as pz
from .base_puzzle_widget import BasePuzzleWidget
from typing import TypedDict
from heurams.services.hasher import hash
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class Setting(TypedDict):
__origin__: str
__hint__: str
primary: str # 显示的提示文本
mapping: dict # 谜题到答案的映射
jammer: list # 干扰项
max_riddles_num: int # 最大谜题数量
prefix: str # 提示词前缀
class MCQPuzzle(BasePuzzleWidget):
def __init__(
self,
*children: Widget,
atom: pt.Atom,
alia: str = "",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True,
) -> None:
super().__init__(
*children,
atom=atom,
name=name,
id=id,
classes=classes,
disabled=disabled,
markup=markup,
)
self.inputlist = []
self.alia = alia
self.hashmap = dict()
self.cursor = 0
self.atom = atom
self._load()
def _load(self):
cfg = self.atom.registry["orbital"]["puzzles"][self.alia]
self.puzzle = pz.MCQPuzzle(
cfg["mapping"], cfg["jammer"], int(cfg["max_riddles_num"]), cfg["prefix"]
)
self.puzzle.refresh()
def compose(self):
self.atom.registry["nucleon"].do_eval()
setting: Setting = self.atom.registry["nucleon"].metadata["orbital"]["puzzles"][
self.alia
]
logger.debug(f"Puzzle Setting: {setting}")
current_options = self.puzzle.options[len(self.inputlist)]
yield Label(setting["primary"], id="sentence")
yield Label(self.puzzle.wording[len(self.inputlist)], id="puzzle")
yield Label(f"当前输入: {self.inputlist}", id="inputpreview")
# 渲染当前问题的选项
with Container(id="btn-container"):
for i in current_options:
self.hashmap[str(hash(i))] = i
btnid = f"sel{str(self.cursor).zfill(3)}-{hash(i)}"
logger.debug(f"建立按钮 {btnid}")
yield Button(i, id=f"{btnid}")
yield Button("退格", id="delete")
def update_display(self, error=0):
# 更新预览标签
preview = self.query_one("#inputpreview")
preview.update(f"当前输入: {self.inputlist}") # type: ignore
logger.debug("已经更新预览标签")
# 更新问题标签
puzzle_label = self.query_one("#puzzle")
current_question_index = len(self.inputlist)
if current_question_index < len(self.puzzle.wording):
puzzle_label.update(self.puzzle.wording[current_question_index]) # type: ignore
def on_button_pressed(self, event: Button.Pressed) -> None:
"""处理按钮点击事件"""
event.stop()
button_id = event.button.id
if button_id == "delete":
# 退格处理
if len(self.inputlist) > 0:
self.inputlist.pop()
self.refresh_buttons()
self.update_display()
elif button_id.startswith("sel"): # type: ignore
# 选项选择处理
answer_text = self.hashmap[button_id[7:]] # type: ignore
self.inputlist.append(answer_text)
logger.debug(f"{self.inputlist}")
# 检查是否完成所有题目
if len(self.inputlist) >= len(self.puzzle.answer):
is_correct = self.inputlist == self.puzzle.answer
rating = 4 if is_correct else 2
self.screen.rating = rating # type: ignore
self.handler(rating)
# 重置输入(如果回答错误)
if not is_correct:
self.inputlist = []
self.refresh_buttons()
self.update_display()
else:
# 进入下一题
self.refresh_buttons()
self.update_display()
def refresh_buttons(self):
"""刷新按钮显示(用于题目切换)"""
# 移除所有选项按钮
logger.debug("刷新按钮")
self.cursor += 1
container = self.query_one("#btn-container")
buttons_to_remove = [
child
for child in container.children
if hasattr(child, "id") and child.id and child.id.startswith("sel")
]
for button in buttons_to_remove:
logger.info(button)
container.remove_children("#" + button.id) # type: ignore
# 添加当前题目的选项按钮
current_question_index = len(self.inputlist)
if current_question_index < len(self.puzzle.options):
current_options = self.puzzle.options[current_question_index]
for option in current_options:
button_id = f"sel{str(self.cursor).zfill(3)}-{hash(option)}"
if button_id not in self.hashmap:
self.hashmap[button_id] = option
new_button = Button(option, id=button_id)
container.mount(new_button)
def handler(self, rating):
if self.atom.lock():
pass
else:
self.atom.minimize(rating)

View File

@@ -0,0 +1,33 @@
from textual.widgets import (
Label,
Button,
)
from textual.widget import Widget
class Placeholder(Widget):
def __init__(
self,
*children: Widget,
name: str | None = None,
alia: str = "",
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True
) -> None:
super().__init__(
*children,
name=name,
id=id,
classes=classes,
disabled=disabled,
markup=markup
)
def compose(self):
yield Label("示例标签", id="testlabel")
yield Button("示例按钮", id="testbtn", classes="choice")
def on_button_pressed(self, event):
pass

View File

@@ -0,0 +1,116 @@
from textual.reactive import reactive
from textual.widgets import (
Markdown,
Label,
Static,
Button,
)
from textual.containers import Center
from textual.widget import Widget
from typing import Dict
import heurams.kernel.particles as pt
import re
from .base_puzzle_widget import BasePuzzleWidget
from typing import TypedDict, List
from textual.message import Message
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class RecognitionConfig(TypedDict):
__origin__: str
__hint__: str
primary: str
secondary: List[str]
top_dim: List[str]
class Recognition(BasePuzzleWidget):
def __init__(
self,
*children: Widget,
atom: pt.Atom,
alia: str = "",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
markup: bool = True,
) -> None:
super().__init__(
*children,
atom=atom,
name=name,
id=id,
classes=classes,
disabled=disabled,
markup=markup,
)
if alia == "":
alia = "Recognition"
self.alia = alia
def compose(self):
cfg: RecognitionConfig = self.atom.registry["orbital"]["puzzles"][self.alia]
delim = self.atom.registry["nucleon"].metadata["formation"]["delimiter"]
replace_dict = {
", ": ",",
". ": ".",
"; ": ";",
": ": ":",
f"{delim},": ",",
f".{delim}": ".",
f"{delim};": ";",
f";{delim}": ";",
f":{delim}": ":",
}
nucleon = self.atom.registry["nucleon"]
metadata = self.atom.registry["nucleon"].metadata
primary = cfg["primary"]
with Center():
yield Static(f"[dim]{cfg['top_dim']}[/]")
yield Label("")
for old, new in replace_dict.items():
primary = primary.replace(old, new)
primary_splited = re.split(r"(?<=[,;:|])", cfg["primary"])
for item in primary_splited:
with Center():
yield Label(
f"[b][b]{item.replace(delim, ' ')}[/][/]",
id="sentence" + str(hash(item)),
)
for item in cfg["secondary"]:
if isinstance(item, list):
for j in item:
yield Markdown(f"### {metadata['annotation'][item]}: {j}")
continue
if isinstance(item, Dict):
total = ""
for j, k in item.items(): # type: ignore
total += f"> **{j}**: {k} \n"
yield Markdown(total)
if isinstance(item, str):
yield Markdown(item)
with Center():
yield Button("我已知晓", id="ok")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok":
self.screen.rating = 5 # type: ignore
self.handler(5)
def handler(self, rating):
if not self.atom.registry["runtime"]["locked"]:
if not self.atom.registry["electron"].is_activated():
self.atom.registry["electron"].activate()
logger.debug(f"激活原子 {self.atom}")
self.atom.lock(1)
self.atom.minimize(5)
else:
pass

View File

@@ -0,0 +1,2 @@
# Kernel - HeurAMS 核心
记忆规划相关算法与数据结构, 可脱离业务层

View File

@@ -0,0 +1,15 @@
from .sm2 import SM2Algorithm
from heurams.services.logger import get_logger
logger = get_logger(__name__)
__all__ = [
"SM2Algorithm",
]
algorithms = {
"SM-2": SM2Algorithm,
"supermemo2": SM2Algorithm,
}
logger.debug("算法模块初始化完成, 注册的算法: %s", list(algorithms.keys()))

View File

@@ -0,0 +1,69 @@
import heurams.services.timer as timer
from typing import TypedDict
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class BaseAlgorithm:
algo_name = "BaseAlgorithm"
class AlgodataDict(TypedDict):
efactor: float
real_rept: int
rept: int
interval: int
last_date: int
next_date: int
is_activated: int
last_modify: float
defaults = {
"real_rept": 0,
"rept": 0,
"interval": 0,
"last_date": 0,
"next_date": 0,
"is_activated": 0,
"last_modify": timer.get_timestamp(),
}
@classmethod
def revisor(
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
) -> None:
"""迭代记忆数据"""
logger.debug(
"BaseAlgorithm.revisor 被调用, algodata keys: %s, feedback: %d, is_new_activation: %s",
list(algodata.keys()) if algodata else [],
feedback,
is_new_activation,
)
pass
@classmethod
def is_due(cls, algodata) -> int:
"""是否应该复习"""
logger.debug(
"BaseAlgorithm.is_due 被调用, algodata keys: %s",
list(algodata.keys()) if algodata else [],
)
return 1
@classmethod
def rate(cls, algodata) -> str:
"""获取评分信息"""
logger.debug(
"BaseAlgorithm.rate 被调用, algodata keys: %s",
list(algodata.keys()) if algodata else [],
)
return ""
@classmethod
def nextdate(cls, algodata) -> int:
"""获取下一次记忆时间戳"""
logger.debug(
"BaseAlgorithm.nextdate 被调用, algodata keys: %s",
list(algodata.keys()) if algodata else [],
)
return -1

View File

@@ -0,0 +1,6 @@
# FSRS 算法模块, 尚未就绪
from heurams.services.logger import get_logger
logger = get_logger(__name__)
logger.info("FSRS算法模块尚未实现")

View File

@@ -0,0 +1,126 @@
from .base import BaseAlgorithm
import heurams.services.timer as timer
from typing import TypedDict
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class SM2Algorithm(BaseAlgorithm):
algo_name = "SM-2"
class AlgodataDict(TypedDict):
efactor: float
real_rept: int
rept: int
interval: int
last_date: int
next_date: int
is_activated: int
last_modify: float
defaults = {
"efactor": 2.5,
"real_rept": 0,
"rept": 0,
"interval": 0,
"last_date": 0,
"next_date": 0,
"is_activated": 0,
"last_modify": timer.get_timestamp(),
}
@classmethod
def revisor(
cls, algodata: dict, feedback: int = 5, is_new_activation: bool = False
):
"""SM-2 算法迭代决策机制实现
根据 quality(0 ~ 5) 进行参数迭代最佳间隔
quality 由主程序评估
Args:
quality (int): 记忆保留率量化参数
"""
logger.debug(
"SM2.revisor 开始, feedback: %d, is_new_activation: %s",
feedback,
is_new_activation,
)
if feedback == -1:
logger.debug("feedback 为 -1, 跳过更新")
return
algodata[cls.algo_name]["efactor"] = algodata[cls.algo_name]["efactor"] + (
0.1 - (5 - feedback) * (0.08 + (5 - feedback) * 0.02)
)
algodata[cls.algo_name]["efactor"] = max(
1.3, algodata[cls.algo_name]["efactor"]
)
logger.debug("更新 efactor: %f", algodata[cls.algo_name]["efactor"])
if feedback < 3:
algodata[cls.algo_name]["rept"] = 0
algodata[cls.algo_name]["interval"] = 0
logger.debug("feedback < 3, 重置 rept 和 interval")
else:
algodata[cls.algo_name]["rept"] += 1
logger.debug("递增 rept: %d", algodata[cls.algo_name]["rept"])
algodata[cls.algo_name]["real_rept"] += 1
logger.debug("递增 real_rept: %d", algodata[cls.algo_name]["real_rept"])
if is_new_activation:
algodata[cls.algo_name]["rept"] = 0
algodata[cls.algo_name]["efactor"] = 2.5
logger.debug("新激活, 重置 rept 和 efactor")
if algodata[cls.algo_name]["rept"] == 0:
algodata[cls.algo_name]["interval"] = 1
logger.debug("rept=0, 设置 interval=1")
elif algodata[cls.algo_name]["rept"] == 1:
algodata[cls.algo_name]["interval"] = 6
logger.debug("rept=1, 设置 interval=6")
else:
algodata[cls.algo_name]["interval"] = round(
algodata[cls.algo_name]["interval"] * algodata[cls.algo_name]["efactor"]
)
logger.debug(
"rept>1, 计算 interval: %d", algodata[cls.algo_name]["interval"]
)
algodata[cls.algo_name]["last_date"] = timer.get_daystamp()
algodata[cls.algo_name]["next_date"] = (
timer.get_daystamp() + algodata[cls.algo_name]["interval"]
)
algodata[cls.algo_name]["last_modify"] = timer.get_timestamp()
logger.debug(
"更新日期: last_date=%d, next_date=%d, last_modify=%f",
algodata[cls.algo_name]["last_date"],
algodata[cls.algo_name]["next_date"],
algodata[cls.algo_name]["last_modify"],
)
@classmethod
def is_due(cls, algodata):
result = algodata[cls.algo_name]["next_date"] <= timer.get_daystamp()
logger.debug(
"SM2.is_due: next_date=%d, current_daystamp=%d, result=%s",
algodata[cls.algo_name]["next_date"],
timer.get_daystamp(),
result,
)
return result
@classmethod
def rate(cls, algodata):
efactor = algodata[cls.algo_name]["efactor"]
logger.debug("SM2.rate: efactor=%f", efactor)
return str(efactor)
@classmethod
def nextdate(cls, algodata) -> int:
next_date = algodata[cls.algo_name]["next_date"]
logger.debug("SM2.nextdate: %d", next_date)
return next_date

View File

@@ -0,0 +1,29 @@
"""
Particle 模块 - 粒子对象系统
提供闪卡所需对象, 使用物理学粒子的领域驱动设计
"""
from heurams.services.logger import get_logger
logger = get_logger(__name__)
logger.debug("粒子模块已加载")
from .electron import Electron
from .nucleon import Nucleon
from .orbital import Orbital
from .atom import Atom, atom_registry
from .probe import probe_all, probe_by_filename
from .loader import load_nucleon, load_electron
__all__ = [
"Electron",
"Nucleon",
"Orbital",
"Atom",
"probe_all",
"probe_by_filename",
"load_nucleon",
"load_electron",
"atom_registry",
]

View File

@@ -0,0 +1,238 @@
from .electron import Electron
from .nucleon import Nucleon
from .orbital import Orbital
from typing import TypedDict
import pathlib
import typing
import toml
import json
import bidict
from heurams.context import config_var
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class AtomRegister_runtime(TypedDict):
locked: bool # 只读锁定标识符
min_rate: int # 最低评分
newact: bool # 新激活
class AtomRegister(TypedDict):
nucleon: Nucleon
nucleon_path: pathlib.Path
nucleon_fmt: str
electron: Electron
electron_path: pathlib.Path
electron_fmt: str
orbital: Orbital
orbital_path: pathlib.Path
orbital_fmt: str
runtime: AtomRegister_runtime
class Atom:
"""
统一处理一系列对象的所有信息与持久化:
关联电子 (算法数据)
关联核子 (内容数据)
关联轨道 (策略数据)
以及关联路径
"""
def __init__(self, ident=""):
logger.debug("创建 Atom 实例, ident: '%s'", ident)
self.ident = ident
atom_registry[ident] = self
logger.debug("Atom 已注册到全局注册表, 当前注册表大小: %d", len(atom_registry))
# self.is_evaled = False
self.registry: AtomRegister = { # type: ignore
"nucleon": None,
"nucleon_path": None,
"nucleon_fmt": "toml",
"electron": None,
"electron_path": None,
"electron_fmt": "json",
"orbital": None,
"orbital_path": None, # 允许设置为 None, 此时使用 nucleon 文件内的推荐配置
"orbital_fmt": "toml",
"runtime": {"locked": False, "min_rate": 0x3F3F3F3F, "newact": False},
}
self.do_eval()
logger.debug("Atom 初始化完成")
def link(self, key, value):
logger.debug("Atom.link: key='%s', value type: %s", key, type(value).__name__)
if key in self.registry.keys():
self.registry[key] = value
logger.debug("'%s' 已链接, 触发 do_eval", key)
self.do_eval()
if key == 'electron':
if self.registry['electron'].is_activated() == 0:
self.registry['runtime']['newact'] = True
else:
logger.error("尝试链接不受支持的键: '%s'", key)
raise ValueError("不受支持的原子元数据链接操作")
def minimize(self, rating):
"""效果等同于 self.registry['runtime']['min_rate'] = min(rating, self.registry['runtime']['min_rate'])
Args:
rating (int): 评分
"""
self.registry["runtime"]["min_rate"] = min(
rating, self.registry["runtime"]["min_rate"]
)
def lock(self, locked=-1):
logger.debug(f"锁定参数 {locked}")
"""锁定, 效果等同于 self.registry['runtime']['locked'] = locked 或者返回是否锁定"""
if locked == 1:
self.registry["runtime"]["locked"] = True
return 1
elif locked == 0:
self.registry["runtime"]["locked"] = False
return 1
elif locked == -1:
return self.registry["runtime"]["locked"]
return 0
def revise(self):
"""执行最终评分
PuzzleWidget 的 handler 除了测试, 严禁直接执行 Electron 的 revisor 函数, 否则造成逻辑混乱
"""
if self.registry["runtime"]["locked"]:
logger.debug(f"允许总评分: {self.registry['runtime']['min_rate']}")
self.registry["electron"].revisor(self.registry["runtime"]["min_rate"], is_new_activation=self.registry["runtime"]["newact"])
else:
logger.debug("禁止总评分")
def do_eval(self):
"""
执行并以结果替换当前单元的所有 eval 语句
TODO: 带有限制的 eval, 异步/多线程执行避免堵塞
"""
logger.debug("Atom.do_eval 开始")
# eval 环境设置
def eval_with_env(s: str):
# 初始化默认值
nucleon = self.registry["nucleon"]
default = {}
metadata = {}
try:
default = config_var.get()["puzzles"]
metadata = nucleon.metadata
except Exception:
# 如果无法获取配置或元数据, 使用空字典
logger.debug("无法获取配置或元数据, 使用空字典")
pass
try:
eval_value = eval(s)
if isinstance(eval_value, (list, dict)):
ret = eval_value
else:
ret = str(eval_value)
logger.debug(
"eval 执行成功: '%s' -> '%s'",
s,
str(ret)[:50] + "..." if len(ret) > 50 else ret,
)
except Exception as e:
ret = f"此 eval 实例发生错误: {e}"
logger.warning("eval 执行错误: '%s' -> %s", s, e)
return ret
def traverse(data, modifier):
if isinstance(data, dict):
for key, value in data.items():
data[key] = traverse(value, modifier)
return data
elif isinstance(data, list):
for i, item in enumerate(data):
data[i] = traverse(item, modifier)
return data
elif isinstance(data, tuple):
return tuple(traverse(item, modifier) for item in data)
else:
if isinstance(data, str):
if data.startswith("eval:"):
logger.debug("发现 eval 表达式: '%s'", data[5:])
return modifier(data[5:])
return data
# 如果 nucleon 存在且有 do_eval 方法, 调用它
nucleon = self.registry["nucleon"]
if nucleon is not None and hasattr(nucleon, "do_eval"):
nucleon.do_eval()
logger.debug("已调用 nucleon.do_eval")
# 如果 electron 存在且其 algodata 包含 eval 字符串, 遍历它
electron = self.registry["electron"]
if electron is not None and hasattr(electron, "algodata"):
traverse(electron.algodata, eval_with_env)
logger.debug("已处理 electron algodata eval")
# 如果 orbital 存在且是字典, 遍历它
orbital = self.registry["orbital"]
if orbital is not None and isinstance(orbital, dict):
traverse(orbital, eval_with_env)
logger.debug("orbital eval 完成")
logger.debug("Atom.do_eval 完成")
def persist(self, key):
logger.debug("Atom.persist: key='%s'", key)
path: pathlib.Path | None = self.registry[key + "_path"]
if isinstance(path, pathlib.Path):
path = typing.cast(pathlib.Path, path)
logger.debug("持久化路径: %s, 格式: %s", path, self.registry[key + "_fmt"])
path.parent.mkdir(parents=True, exist_ok=True)
if self.registry[key + "_fmt"] == "toml":
with open(path, "r+") as f:
f.seek(0)
f.truncate()
toml.dump(self.registry[key], f)
logger.debug("TOML 数据已保存到: %s", path)
elif self.registry[key + "_fmt"] == "json":
with open(path, "r+") as f:
origin = json.load(f)
f.seek(0)
f.truncate()
origin[self.ident] = self.registry[key].algodata
json.dump(origin, f, indent=2, ensure_ascii=False)
logger.debug("JSON 数据已保存到: %s", path)
else:
logger.error("不受支持的持久化格式: %s", self.registry[key + "_fmt"])
raise KeyError("不受支持的持久化格式")
else:
logger.error("路径未初始化: %s_path", key)
raise TypeError("对未初始化的路径对象操作")
def __getitem__(self, key):
logger.debug("Atom.__getitem__: key='%s'", key)
if key in self.registry:
value = self.registry[key]
logger.debug("返回 value type: %s", type(value).__name__)
return value
logger.error("不支持的键: '%s'", key)
raise KeyError(f"不支持的键: {key}")
def __setitem__(self, key, value):
logger.debug(
"Atom.__setitem__: key='%s', value type: %s", key, type(value).__name__
)
if key in self.registry:
self.registry[key] = value
logger.debug("'%s' 已设置", key)
else:
logger.error("不支持的键: '%s'", key)
raise KeyError(f"不支持的键: {key}")
@staticmethod
def placeholder():
return (Electron.placeholder(), Nucleon.placeholder(), {})
atom_registry: bidict.bidict[str, Atom] = bidict.bidict()

View File

@@ -0,0 +1,151 @@
import heurams.services.timer as timer
from heurams.context import config_var
from heurams.kernel.algorithms import algorithms
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class Electron:
"""电子: 记忆分析元数据及算法"""
def __init__(self, ident: str, algodata: dict = {}, algo_name: str = "supermemo2"):
"""初始化电子对象 (记忆数据)
Args:
ident: 算法的唯一标识符, 用于区分不同的算法实例, 使用 algodata[ident] 获取
algodata: 算法数据字典, 包含算法的各项参数和设置
algo: 使用的算法模块标识
"""
logger.debug(
"创建 Electron 实例, ident: '%s', algo_name: '%s'", ident, algo_name
)
self.algodata = algodata
self.ident = ident
self.algo = algorithms[algo_name]
logger.debug("使用的算法类: %s", self.algo.__name__)
if self.algo not in self.algodata.keys():
self.algodata[self.algo.algo_name] = {}
logger.debug("算法键 '%s' 不存在, 已创建空字典", self.algo)
if not self.algodata[self.algo.algo_name]:
logger.debug("算法数据为空, 使用默认值初始化")
self._default_init(self.algo.defaults)
else:
logger.debug("算法数据已存在, 跳过默认初始化")
logger.debug(
"Electron 初始化完成, algodata keys: %s", list(self.algodata.keys())
)
def _default_init(self, defaults: dict):
"""默认初始化包装"""
logger.debug(
"Electron._default_init: 使用默认值, keys: %s", list(defaults.keys())
)
self.algodata[self.algo.algo_name] = defaults.copy()
def activate(self):
"""激活此电子"""
logger.debug("Electron.activate: 激活 ident='%s'", self.ident)
self.algodata[self.algo.algo_name]["is_activated"] = 1
self.algodata[self.algo.algo_name]["last_modify"] = timer.get_timestamp()
logger.debug("电子已激活, is_activated=1")
def modify(self, var: str, value):
"""修改 algodata[algo] 中子字典数据"""
logger.debug("Electron.modify: var='%s', value=%s", var, value)
if var in self.algodata[self.algo.algo_name]:
self.algodata[self.algo.algo_name][var] = value
self.algodata[self.algo.algo_name]["last_modify"] = timer.get_timestamp()
logger.debug("变量 '%s' 已修改, 更新 last_modify", var)
else:
logger.warning("'%s' 非已知元数据字段", var)
print(f"警告: '{var}' 非已知元数据字段")
def is_due(self):
"""是否应该复习"""
logger.debug("Electron.is_due: 检查 ident='%s'", self.ident)
result = self.algo.is_due(self.algodata)
logger.debug("is_due 结果: %s", result)
return result and self.is_activated()
def is_activated(self):
result = self.algodata[self.algo.algo_name]["is_activated"]
logger.debug("Electron.is_activated: ident='%s', 结果: %d", self.ident, result)
return result
def get_rate(self):
"评价"
try:
logger.debug("Electron.rate: ident='%s'", self.ident)
result = self.algo.rate(self.algodata)
logger.debug("rate 结果: %s", result)
return result
except:
return 0
def nextdate(self) -> int:
logger.debug("Electron.nextdate: ident='%s'", self.ident)
result = self.algo.nextdate(self.algodata)
logger.debug("nextdate 结果: %d", result)
return result
def revisor(self, quality: int = 5, is_new_activation: bool = False):
"""算法迭代决策机制实现
Args:
quality (int): 记忆保留率量化参数 (0-5)
is_new_activation (bool): 是否为初次激活
"""
logger.debug(
"Electron.revisor: ident='%s', quality=%d, is_new_activation=%s",
self.ident,
quality,
is_new_activation,
)
self.algo.revisor(self.algodata, quality, is_new_activation)
logger.debug(
"revisor 完成, 更新后的 algodata: %s", self.algodata.get(self.algo, {})
)
def __str__(self):
return (
f"记忆单元预览 \n"
f"标识符: '{self.ident}' \n"
f"算法: {self.algo} \n"
f"易度系数: {self.algodata[self.algo.algo_name]['efactor']:.2f} \n"
f"已经重复的次数: {self.algodata[self.algo.algo_name]['rept']} \n"
f"下次间隔: {self.algodata[self.algo.algo_name]['interval']}\n"
f"下次复习日期时间戳: {self.algodata[self.algo.algo_name]['next_date']}"
)
def __eq__(self, other):
if self.ident == other.ident:
return True
return False
def __hash__(self):
return hash(self.ident)
def __getitem__(self, key):
if key == "ident":
return self.ident
if key in self.algodata[self.algo.algo_name]:
return self.algodata[self.algo.algo_name][key]
else:
raise KeyError(f"'{key}' 未在 algodata[self.algo] 中")
def __setitem__(self, key, value):
if key == "ident":
raise AttributeError("ident 应为只读")
self.algodata[self.algo.algo_name][key] = value
self.algodata[self.algo.algo_name]["last_modify"] = timer.get_timestamp()
def __len__(self):
"""仅返回当前算法的配置数量"""
return len(self.algodata[self.algo.algo_name])
@staticmethod
def placeholder():
"""生成一个电子占位符"""
return Electron("电子对象样例内容", {})

View File

@@ -0,0 +1,71 @@
from .nucleon import Nucleon
from .electron import Electron
import heurams.services.hasher as hasher
import pathlib
import toml
import json
from copy import deepcopy
from heurams.services.logger import get_logger
logger = get_logger(__name__)
def load_nucleon(path: pathlib.Path, fmt="toml"):
logger.debug("load_nucleon: 加载文件 %s, 格式: %s", path, fmt)
with open(path, "r") as f:
dictdata = dict()
dictdata = toml.load(f) # type: ignore
logger.debug("TOML 解析成功, keys: %s", list(dictdata.keys()))
lst = list()
nested_data = dict()
# 修正 toml 解析器的不管嵌套行为
for key, value in dictdata.items():
if "__metadata__" in key: # 以免影响句号
if "." in key:
parts = key.split(".")
current = nested_data
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
logger.debug("处理元数据键: %s", key)
else:
nested_data[key] = value
logger.debug("嵌套数据处理完成, keys: %s", list(nested_data.keys()))
# print(nested_data)
for item, attr in nested_data.items():
if item == "__metadata__":
continue
logger.debug("处理项目: %s", item)
lst.append(
(
Nucleon(item, attr, deepcopy(nested_data["__metadata__"])),
deepcopy(nested_data["__metadata__"]["orbital"]),
)
)
logger.debug("load_nucleon 完成, 加载了 %d 个 Nucleon 对象", len(lst))
return lst
def load_electron(path: pathlib.Path, fmt="json") -> dict:
"""从文件路径加载电子对象
Args:
path (pathlib.Path): 路径
fmt (str): 文件格式(可选, 默认 json)
Returns:
dict: 键名是电子对象名称, 值是电子对象
"""
logger.debug("load_electron: 加载文件 %s, 格式: %s", path, fmt)
with open(path, "r") as f:
dictdata = dict()
dictdata = json.load(f) # type: ignore
logger.debug("JSON 解析成功, keys: %s", list(dictdata.keys()))
dic = dict()
for item, attr in dictdata.items():
logger.debug("处理电子项目: %s", item)
dic[item] = Electron(item, attr)
logger.debug("load_electron 完成, 加载了 %d 个 Electron 对象", len(dic))
return dic

View File

@@ -0,0 +1,104 @@
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class Nucleon:
"""原子核: 材料元数据"""
def __init__(self, ident: str, payload: dict, metadata: dict = {}):
"""初始化原子核 (记忆内容)
Args:
ident: 唯一标识符
payload: 记忆内容信息
metadata: 可选元数据信息
"""
logger.debug(
"创建 Nucleon 实例, ident: '%s', payload keys: %s, metadata keys: %s",
ident,
list(payload.keys()) if payload else [],
list(metadata.keys()) if metadata else [],
)
self.metadata = metadata
self.payload = payload
self.ident = ident
logger.debug("Nucleon 初始化完成")
def __getitem__(self, key):
logger.debug("Nucleon.__getitem__: key='%s'", key)
if key == "ident":
logger.debug("返回 ident: '%s'", self.ident)
return self.ident
if key in self.payload:
value = self.payload[key]
logger.debug(
"返回 payload['%s'], value type: %s", key, type(value).__name__
)
return value
else:
logger.error("'%s' 未在 payload 中找到", key)
raise KeyError(f"Key '{key}' not found in payload.")
def __iter__(self):
yield from self.payload.keys()
def __len__(self):
return len(self.payload)
def __hash__(self):
return hash(self.ident)
def do_eval(self):
"""
执行并以结果替换当前单元的所有 eval 语句
TODO: 带有限制的 eval, 异步/多线程执行避免堵塞
"""
logger.debug("Nucleon.do_eval 开始")
# eval 环境设置
def eval_with_env(s: str):
try:
nucleon = self
eval_value = eval(s)
if isinstance(eval_value, (int, float)):
ret = str(eval_value)
else:
ret = eval_value
logger.debug(
"eval 执行成功: '%s' -> '%s'",
s,
str(ret)[:50] + "..." if len(ret) > 50 else ret,
)
except Exception as e:
ret = f"此 eval 实例发生错误: {e}"
logger.warning("eval 执行错误: '%s' -> %s", s, e)
return ret
def traverse(data, modifier):
if isinstance(data, dict):
for key, value in data.items():
data[key] = traverse(value, modifier)
return data
elif isinstance(data, list):
for i, item in enumerate(data):
data[i] = traverse(item, modifier)
return data
elif isinstance(data, tuple):
return tuple(traverse(item, modifier) for item in data)
else:
if isinstance(data, str):
if data.startswith("eval:"):
logger.debug("发现 eval 表达式: '%s'", data[5:])
return modifier(data[5:])
return data
traverse(self.payload, eval_with_env)
traverse(self.metadata, eval_with_env)
logger.debug("Nucleon.do_eval 完成")
@staticmethod
def placeholder():
"""生成一个占位原子核"""
logger.debug("创建 Nucleon 占位符")
return Nucleon("核子对象样例内容", {})

View File

@@ -0,0 +1,29 @@
from typing import TypedDict
from heurams.services.logger import get_logger
logger = get_logger(__name__)
logger.debug("Orbital 类型定义模块已加载")
class OrbitalSchedule(TypedDict):
quick_review: list
recognition: list
final_review: list
class Orbital(TypedDict):
schedule: OrbitalSchedule
puzzles: dict
"""一份示例
["__metadata__.orbital.puzzles"] # 谜题定义
"Recognition" = { __origin__ = "recognition", __hint__ = "", primary = "eval:nucleon['content']", secondery = ["eval:nucleon['keyword_note']", "eval:nucleon['note']"], top_dim = ["eval:nucleon['translation']"] }
"SelectMeaning" = { __origin__ = "mcq", __hint__ = "eval:nucleon['content']", jammer = "eval:nucleon['keyword_note']", max_riddles_num = "eval:default['mcq']['max_riddles_num']", prefix = "选择正确项: " }
"FillBlank" = { __origin__ = "cloze", __hint__ = "", text = "eval:nucleon['content']", delimiter = "eval:metadata['formation']['delimiter']", min_denominator = "eval:default['cloze']['min_denominator']"}
["__metadata__.orbital.schedule"] # 内置的推荐学习方案
quick_review = [["FillBlank", "1.0"], ["SelectMeaning", "0.5"], ["recognition", "1.0"]]
recognition = [["recognition", "1.0"]]
final_review = [["FillBlank", "0.7"], ["SelectMeaning", "0.7"], ["recognition", "1.0"]]
"""

View File

@@ -0,0 +1,61 @@
from heurams.context import config_var
import pathlib
from heurams.services.logger import get_logger
logger = get_logger(__name__)
def probe_by_filename(filename):
"""探测指定文件 (无扩展名) 的所有信息"""
logger.debug("probe_by_filename: 探测文件 '%s'", filename)
paths: dict = config_var.get().get("paths")
logger.debug("配置路径: %s", paths)
formats = ["toml", "json"]
result = {}
for item, attr in paths.items():
for i in formats:
attr: pathlib.Path = pathlib.Path(attr) / filename + "." + i
if attr.exists():
logger.debug("找到文件: %s", attr)
result[item.replace("_dir", "")] = str(attr)
else:
logger.debug("文件不存在: %s", attr)
logger.debug("probe_by_filename 结果: %s", result)
return result
def probe_all(is_stem=1):
"""依据目录探测所有信息
Args:
is_stem (boolean): 是否**删除**文件扩展名
Returns:
dict: 有三项, 每一项的键名都是文件组类型, 值都是文件组列表, 只包含文件名
"""
logger.debug("probe_all: 开始探测, is_stem=%d", is_stem)
paths: dict = config_var.get().get("paths")
logger.debug("配置路径: %s", paths)
result = {}
for item, attr in paths.items():
attr: pathlib.Path = pathlib.Path(attr)
result[item.replace("_dir", "")] = list()
logger.debug("扫描目录: %s", attr)
file_count = 0
for i in attr.iterdir():
if not i.is_dir():
file_count += 1
if is_stem:
result[item.replace("_dir", "")].append(str(i.stem))
else:
result[item.replace("_dir", "")].append(str(i.name))
logger.debug("目录 %s 中找到 %d 个文件", attr, file_count)
logger.debug("probe_all 完成, 结果 keys: %s", list(result.keys()))
return result
if __name__ == "__main__":
import os
print(os.getcwd())
print(probe_all())

View File

@@ -0,0 +1,63 @@
"""
Puzzle 模块 - 谜题生成系统
提供多种类型的谜题生成器, 支持从字符串、字典等数据源导入题目
"""
from heurams.services.logger import get_logger
logger = get_logger(__name__)
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,
}
@staticmethod
def create_by_dict(config_dict: dict) -> BasePuzzle:
"""
根据配置字典创建谜题
Args:
config_dict: 配置字典, 包含谜题类型和参数
Returns:
BasePuzzle: 谜题实例
Raises:
ValueError: 当配置无效时抛出
"""
logger.debug(
"puzzles.create_by_dict: config_dict keys=%s", list(config_dict.keys())
)
puzzle_type = config_dict.get("type")
if puzzle_type == "cloze":
return puzzles["cloze"](
text=config_dict["text"],
min_denominator=config_dict.get("min_denominator", 7),
)
elif puzzle_type == "mcq":
return puzzles["mcq"](
mapping=config_dict["mapping"],
jammer=config_dict.get("jammer", []),
max_riddles_num=config_dict.get("max_riddles_num", 2),
prefix=config_dict.get("prefix", ""),
)
else:
raise ValueError(f"未知的谜题类型: {puzzle_type}")

View File

@@ -0,0 +1,16 @@
# base.py
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class BasePuzzle:
"""谜题基类"""
def refresh(self):
logger.debug("BasePuzzle.refresh 被调用(未实现)")
raise NotImplementedError("谜题对象未实现 refresh 方法")
def __str__(self):
logger.debug("BasePuzzle.__str__ 被调用")
return f"谜题: {type(self).__name__}"

View File

@@ -0,0 +1,55 @@
from .base import BasePuzzle
import random
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class ClozePuzzle(BasePuzzle):
"""填空题谜题生成器
Args:
text: 原始字符串(需要 delimiter 分割句子, 末尾应有 delimiter)
min_denominator: 最小概率倒数(如占所有可生成填空数的 1/7 中的 7, 若期望值小于 1, 则取 1)
"""
def __init__(self, text: str, min_denominator: int, delimiter: str = "/"):
logger.debug(
"ClozePuzzle.__init__: text length=%d, min_denominator=%d, delimiter='%s'",
len(text),
min_denominator,
delimiter,
)
self.text = text
self.min_denominator = min_denominator
self.wording = "填空题 - 尚未刷新谜题"
self.answer = ["填空题 - 尚未刷新谜题"]
self.delimiter = delimiter
logger.debug("ClozePuzzle 初始化完成")
def refresh(self): # 刷新谜题
logger.debug("ClozePuzzle.refresh 开始")
placeholder = "___SLASH___"
tmp_text = self.text.replace(self.delimiter, placeholder)
words = tmp_text.split(placeholder)
if not words:
logger.warning("ClozePuzzle.refresh: 无单词可处理")
return
words = [word for word in words if word]
logger.debug("ClozePuzzle.refresh: 分割出 %d 个单词", len(words))
num_blanks = min(max(1, len(words) // self.min_denominator), len(words))
logger.debug("ClozePuzzle.refresh: 需要生成 %d 个填空", num_blanks)
indices_to_blank = random.sample(range(len(words)), num_blanks)
indices_to_blank.sort()
blanked_words = list(words)
answer = list()
for index in indices_to_blank:
blanked_words[index] = "__" * len(words[index])
answer.append(words[index])
self.answer = answer
self.wording = "".join(blanked_words)
logger.debug("ClozePuzzle.refresh 完成, 生成 %d 个填空", len(answer))
def __str__(self):
logger.debug("ClozePuzzle.__str__ 被调用")
return f"{self.wording}\n{str(self.answer)}"

View File

@@ -0,0 +1,242 @@
# mcq.py
from .base import BasePuzzle
import random
from typing import List, Dict, Optional, Union
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class MCQPuzzle(BasePuzzle):
"""选择题谜题生成器
该类用于生成和管理选择题谜题, 支持多个题目同时生成,
每个题目包含问题, 正确答案和干扰项选项.
Attributes:
prefix (str): 题目前缀文本
mapping (Dict[str, str]): 问题和正确答案的映射字典
jammer (List[str]): 干扰项列表
max_riddles_num (int): 最大题目数量限制
wording (Union[str, List[str]]): 题目文本内容
answer (Union[str, List[str]]): 正确答案列表
options (List[List[str]]): 每个题目的选项列表
"""
def __init__(
self,
mapping: Dict[str, str],
jammer: List[str],
max_riddles_num: int = 2,
prefix: str = "",
) -> None:
"""初始化选择题谜题生成器
Args:
mapping: 问题和正确答案的映射字典, 键为问题, 值为正确答案
jammer: 干扰项列表, 用于生成错误选项
max_riddles_num: 每次生成的最大题目数量, 范围限制在1-5之间
prefix: 题目前缀文本, 会显示在每个题目之前
"""
logger.debug(
"MCQPuzzle.__init__: mapping size=%d, jammer size=%d, max_riddles_num=%d",
len(mapping),
len(jammer),
max_riddles_num,
)
self.prefix = prefix
self.mapping = mapping
self.max_riddles_num = max(1, min(max_riddles_num, 5))
# 初始化干扰项, 确保至少有4个选项
self._init_jammer(jammer)
# 初始化题目状态
self._reset_puzzle_state()
def _init_jammer(self, jammer: List[str]) -> None:
"""初始化干扰项列表
合并传入的干扰项和所有正确答案, 确保去重后至少有4个干扰项.
Args:
jammer: 传入的干扰项列表
"""
# 合并正确答案和传入的干扰项, 并去重
logger.debug(f"答案映射: {self.mapping}, {type(self.mapping)}")
logger.debug(f"干扰项: {jammer}, {type(jammer)}")
unique_jammers = set(jammer + list(self.mapping.values()))
self.jammer = list(unique_jammers)
# 确保至少有4个干扰项
while len(self.jammer) < 4:
self.jammer.append(" " * (4 - len(self.jammer)))
unique_jammers = set(jammer + list(self.mapping.values()))
def _reset_puzzle_state(self) -> None:
"""重置谜题状态为初始值
将题目文本, 答案和选项重置为默认状态.
"""
self.wording: Union[str, List[str]] = "选择题 - 尚未刷新谜题"
self.answer: Union[str, List[str]] = ["选择题 - 尚未刷新谜题"]
self.options: List[List[str]] = []
def refresh(self) -> None:
"""刷新谜题, 生成指定数量的选择题
从mapping中随机选择指定数量的问题, 为每个问题生成包含正确答案
和干扰项的选项列表, 并更新谜题状态.
Raises:
ValueError: 当mapping为空时不会抛出异常, 但会设置空谜题状态
"""
logger.debug("MCQPuzzle.refresh 开始, mapping size=%d", len(self.mapping))
if not self.mapping:
self._set_empty_puzzle()
return
num_questions = min(self.max_riddles_num, len(self.mapping))
selected_questions = random.sample(list(self.mapping.items()), num_questions)
puzzles: List[str] = []
answers: List[str] = []
all_options: List[List[str]] = []
for question, correct_answer in selected_questions:
options = self._generate_options(correct_answer)
puzzles.append(question)
answers.append(correct_answer)
all_options.append(options)
self.wording = self._format_questions(puzzles)
self.answer = answers
self.options = all_options
def _set_empty_puzzle(self) -> None:
"""设置为空谜题状态
当没有可用的题目时, 设置相应的提示信息.
"""
self.wording = "无可用题目"
self.answer = ["无答案"]
self.options = []
def _generate_options(self, correct_answer: str) -> List[str]:
"""为单个问题生成选项列表(包含正确答案和干扰项)
Args:
correct_answer: 当前问题的正确答案
Returns:
包含4个选项的列表, 其中一个是正确答案, 三个是干扰项
Note:
如果可用干扰项不足3个, 会使用重复的干扰项填充
"""
options = [correct_answer]
# 获取可用的干扰项(排除正确答案)
available_jammers = [
jammer for jammer in self.jammer if jammer != correct_answer
]
# 选择3个干扰项
if len(available_jammers) >= 3:
selected_jammers = random.sample(available_jammers, 3)
else:
selected_jammers = random.choices(available_jammers, k=3)
options.extend(selected_jammers)
random.shuffle(options)
return options
def _format_questions(self, puzzles: List[str]) -> List[str]:
"""格式化问题列表为可读的文本
Args:
puzzles: 原始问题文本列表
Returns:
格式化后的问题文本列表, 包含编号和前缀
Example:
输入: ["问题1", "问题2"]
输出: ["前缀:\\n 1. 问题1", "前缀:\\n 2. 问题2"]
"""
if not puzzles:
return []
formatted_questions = []
for i, puzzle in enumerate(puzzles, 1):
question_text = (
f"{self.prefix}:\n {i}. {puzzle}" if self.prefix else f"{i}. {puzzle}"
)
formatted_questions.append(question_text)
return formatted_questions
def __str__(self) -> str:
"""返回谜题的字符串表示
Returns:
包含所有问题和正确答案的格式化字符串
Example:
选择题 - 尚未刷新谜题
正确答案: 选择题 - 尚未刷新谜题
"""
if isinstance(self.wording, list):
question_text = "\n".join(self.wording)
else:
question_text = self.wording
if isinstance(self.answer, list):
answer_text = ", ".join(self.answer)
else:
answer_text = str(self.answer)
return f"{question_text}\n正确答案: {answer_text}"
def get_question_count(self) -> int:
"""获取当前生成的题目数量
Returns:
当前题目的数量, 如果尚未刷新则返回 0
"""
if isinstance(self.wording, list):
return len(self.wording)
elif self.wording == "选择题 - 尚未刷新谜题" or self.wording == "无可用题目":
return 0
else:
return 1
def get_correct_answer_for_question(self, question_index: int) -> Optional[str]:
"""获取指定题目的正确答案
Args:
question_index: 题目索引(从0开始)
Returns:
指定题目的正确答案, 如果索引无效则返回None
"""
if not isinstance(self.answer, list):
return None
if 0 <= question_index < len(self.answer):
return self.answer[question_index]
return None
def get_options_for_question(self, question_index: int) -> Optional[List[str]]:
"""获取指定题目的选项列表
Args:
question_index: 题目索引(从0开始)
Returns:
指定题目的选项列表, 如果索引无效则返回None
"""
if 0 <= question_index < len(self.options):
return self.options[question_index]
return None

View File

@@ -0,0 +1,18 @@
# mcq.py
from .base import BasePuzzle
import random
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class RecognitionPuzzle(BasePuzzle):
"""识别占位符"""
def __init__(self) -> None:
logger.debug("RecognitionPuzzle.__init__")
super().__init__()
def refresh(self):
logger.debug("RecognitionPuzzle.refresh空实现")
pass

View File

@@ -0,0 +1,11 @@
from .states import PhaserState, ProcessionState
from .procession import Procession
from .fission import Fission
from .phaser import Phaser
from heurams.services.logger import get_logger
logger = get_logger(__name__)
__all__ = ["PhaserState", "ProcessionState", "Procession", "Fission", "Phaser"]
logger.debug("反应堆模块已加载")

View File

@@ -0,0 +1,43 @@
import heurams.kernel.particles as pt
import heurams.kernel.puzzles as puz
import random
from .states import PhaserState
from heurams.services.logger import get_logger
class Fission:
"""裂变器: 单原子调度展开器"""
def __init__(self, atom: pt.Atom, phase=PhaserState.RECOGNITION):
self.logger = get_logger(__name__)
self.atom = atom
# print(f"{phase.value}")
self.orbital_schedule = atom.registry["orbital"]["schedule"][phase.value] # type: ignore
self.orbital_puzzles = atom.registry["orbital"]["puzzles"]
# print(self.orbital_schedule)
self.puzzles = list()
for item, possibility in self.orbital_schedule: # type: ignore
print(f"ad:{item}")
self.logger.debug(f"开始处理 orbital 项: {item}")
if not isinstance(possibility, float):
possibility = float(possibility)
while possibility > 1:
self.puzzles.append(
{
"puzzle": puz.puzzles[self.orbital_puzzles[item]["__origin__"]],
"alia": item,
}
)
possibility -= 1
if random.random() <= possibility:
self.puzzles.append(
{
"puzzle": puz.puzzles[self.orbital_puzzles[item]["__origin__"]],
"alia": item,
}
)
print(f"ok:{item}")
self.logger.debug(f"orbital 项处理完成: {item}")
def generate(self):
yield from self.puzzles

View File

@@ -0,0 +1,50 @@
# 移相器类定义
import heurams.kernel.particles as pt
from .states import PhaserState, ProcessionState
from .procession import Procession
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class Phaser:
"""移相器: 全局调度阶段管理器"""
def __init__(self, atoms: list[pt.Atom]) -> None:
logger.debug("Phaser.__init__: 原子数量=%d", len(atoms))
new_atoms = list()
old_atoms = list()
self.state = PhaserState.UNSURE
for i in atoms:
if not i.registry["electron"].is_activated():
new_atoms.append(i)
else:
old_atoms.append(i)
logger.debug("新原子数量=%d, 旧原子数量=%d", len(new_atoms), len(old_atoms))
self.processions = list()
if len(old_atoms):
self.processions.append(
Procession(old_atoms, PhaserState.QUICK_REVIEW, "初始复习")
)
logger.debug("创建初始复习 Procession")
if len(new_atoms):
self.processions.append(
Procession(new_atoms, PhaserState.RECOGNITION, "新记忆")
)
logger.debug("创建新记忆 Procession")
self.processions.append(Procession(atoms, PhaserState.FINAL_REVIEW, "总体复习"))
logger.debug("创建总体复习 Procession")
logger.debug("Phaser 初始化完成, processions 数量=%d", len(self.processions))
def current_procession(self):
logger.debug("Phaser.current_procession 被调用")
for i in self.processions:
i: Procession
if not i.state == ProcessionState.FINISHED:
self.state = i.phase
logger.debug("找到未完成的 Procession: phase=%s", i.phase)
return i
self.state = PhaserState.FINISHED
logger.debug("所有 Procession 已完成, 状态设置为 FINISHED")
return 0

View File

@@ -0,0 +1,73 @@
import heurams.kernel.particles as pt
from .states import PhaserState, ProcessionState
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class Procession:
"""队列: 标识单次记忆流程"""
def __init__(self, atoms: list, phase: PhaserState, name: str = ""):
logger.debug(
"Procession.__init__: 原子数量=%d, phase=%s, name='%s'",
len(atoms),
phase.value,
name,
)
self.atoms = atoms
self.queue = atoms.copy()
self.current_atom = atoms[0]
self.cursor = 0
self.name = name
self.phase = phase
self.state: ProcessionState = ProcessionState.RUNNING
logger.debug("Procession 初始化完成, 队列长度=%d", len(self.queue))
def forward(self, step=1):
logger.debug("Procession.forward: step=%d, 当前 cursor=%d", step, self.cursor)
self.cursor += step
if self.cursor == len(self.queue):
self.state = ProcessionState.FINISHED
logger.debug("Procession 已完成")
else:
self.state = ProcessionState.RUNNING
try:
logger.debug("cursor 更新为: %d", self.cursor)
self.current_atom = self.queue[self.cursor]
logger.debug("当前原子更新为: %s", self.current_atom.ident)
return 1 # 成功
except IndexError as e:
logger.debug("IndexError: %s", e)
self.state = ProcessionState.FINISHED
logger.debug("Procession 因索引错误而完成")
return 0
def append(self, atom=None):
if atom == None:
atom = self.current_atom
logger.debug("Procession.append: atom=%s", atom.ident if atom else "None")
if self.queue[len(self.queue) - 1] != atom or len(self) <= 1:
self.queue.append(atom)
logger.debug("原子已追加到队列, 新队列长度=%d", len(self.queue))
else:
logger.debug("原子未追加(重复或队列长度<=1")
def __len__(self):
length = len(self.queue) - self.cursor
logger.debug("Procession.__len__: 剩余长度=%d", length)
return length
def process(self):
logger.debug("Procession.process: cursor=%d", self.cursor)
return self.cursor
def total_length(self):
total = len(self.queue)
logger.debug("Procession.total_length: %d", total)
return total
def is_empty(self):
empty = len(self.queue)
logger.debug("Procession.is_empty: %d", empty)
return empty

View File

@@ -0,0 +1,20 @@
from enum import Enum, auto
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class PhaserState(Enum):
UNSURE = "unsure"
QUICK_REVIEW = "quick_review"
RECOGNITION = "recognition"
FINAL_REVIEW = "final_review"
FINISHED = "finished"
class ProcessionState(Enum):
RUNNING = auto()
FINISHED = auto()
logger.debug("状态枚举定义已加载")

View File

@@ -0,0 +1,2 @@
# Provider - 提供者
底层相关与第三方 API 接口包装

View File

@@ -0,0 +1,14 @@
# 音频播放器, 必须基于文件操作
from . import termux_audio
from . import playsound_audio
from heurams.services.logger import get_logger
logger = get_logger(__name__)
__all__ = [
"termux_audio",
"playsound_audio",
]
providers = {"termux": termux_audio, "playsound": playsound_audio}
logger.debug("音频 providers 已注册: %s", list(providers.keys()))

View File

@@ -0,0 +1,21 @@
"""通用音频适配器
基于 playsound 库的音频播放器, 在绝大多数 python 环境上提供音频服务
注意: 在未配置 pulseaudio 的 termux 不可用
"""
import os
import pathlib
import playsound
from heurams.services.logger import get_logger
logger = get_logger(__name__)
def play_by_path(path: pathlib.Path):
logger.debug("playsound_audio.play_by_path: 开始播放 %s", path)
try:
playsound.playsound(str(path))
logger.debug("播放完成: %s", path)
except Exception as e:
logger.error("播放失败: %s, 错误: %s", path, e)
raise

View File

@@ -0,0 +1,12 @@
from typing import Protocol
import pathlib
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

@@ -0,0 +1,22 @@
"""Termux 音频适配
适配 Termux 的 play-audio 命令, 以在 android 上提供可用的播放体验
无需配置 pulseaudio
"""
import os
import pathlib
from heurams.services.logger import get_logger
logger = get_logger(__name__)
# from .protocol import PlayFunctionProtocol
def play_by_path(path: pathlib.Path):
logger.debug("termux_audio.play_by_path: 开始播放 %s", path)
try:
os.system(f"play-audio {path}")
logger.debug("播放命令已执行: %s", path)
except Exception as e:
logger.error("播放失败: %s, 错误: %s", path, e)
raise

View File

@@ -0,0 +1,6 @@
# 大语言模型
from heurams.services.logger import get_logger
logger = get_logger(__name__)
logger.debug("LLM providers 模块已加载")

View File

@@ -0,0 +1,5 @@
from heurams.services.logger import get_logger
logger = get_logger(__name__)
logger.debug("LLM 基类模块已加载")

View File

@@ -0,0 +1,5 @@
from heurams.services.logger import get_logger
logger = get_logger(__name__)
logger.debug("OpenAI provider 模块已加载(未实现)")

View File

@@ -0,0 +1,17 @@
from .base import BaseTTS
from .edge_tts import EdgeTTS
from heurams.services.logger import get_logger
logger = get_logger(__name__)
__all__ = [
"BaseTTS",
"EdgeTTS",
]
providers = {
"basetts": BaseTTS,
"edgetts": EdgeTTS,
}
logger.debug("TTS providers 已注册: %s", list(providers.keys()))

View File

@@ -0,0 +1,15 @@
import pathlib
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class BaseTTS:
name = "BaseTTS"
@classmethod
def convert(cls, text: str, path: pathlib.Path | str = "") -> pathlib.Path:
"""path 是可选参数, 不填则自动返回生成文件路径"""
logger.debug("BaseTTS.convert: text length=%d, path=%s", len(text), path)
logger.warning("BaseTTS.convert 是基类方法, 未实现具体功能")
return path # type: ignore

View File

@@ -0,0 +1,26 @@
from .base import BaseTTS
import pathlib
import edge_tts
from heurams.services.logger import get_logger
logger = get_logger(__name__)
class EdgeTTS(BaseTTS):
name = "EdgeTTS"
@classmethod
def convert(cls, text, path: pathlib.Path | str = "") -> pathlib.Path:
logger.debug("EdgeTTS.convert: text length=%d, path=%s", len(text), path)
try:
communicate = edge_tts.Communicate(
text,
"zh-CN-YunjianNeural",
)
logger.debug("EdgeTTS 通信对象创建成功, 正在保存音频")
communicate.save_sync(str(path))
logger.debug("EdgeTTS 音频已保存到: %s", path)
return path # type: ignore
except Exception as e:
logger.error("EdgeTTS.convert 失败: %s", e)
raise

View File

@@ -0,0 +1,2 @@
# Services - 服务
基础服务相关代码

View File

@@ -0,0 +1,12 @@
# 音频服务
from heurams.context import config_var
from heurams.providers.audio import providers as prov
from typing import Callable
from heurams.services.logger import get_logger
logger = get_logger(__name__)
play_by_path: Callable = prov[config_var.get()["services"]["audio"]].play_by_path
logger.debug(
"音频服务初始化完成, 使用 provider: %s", config_var.get()["services"]["audio"]
)

View File

@@ -0,0 +1,55 @@
# 配置文件服务
import pathlib
import toml
import typing
from heurams.services.logger import get_logger
class ConfigFile:
def __init__(self, path: pathlib.Path):
self.logger = get_logger(__name__)
self.path = path
if not self.path.exists():
self.path.touch()
self.logger.debug("创建配置文件: %s", self.path)
self.data = dict()
self._load()
def _load(self):
"""从文件加载配置数据"""
with open(self.path, "r") as f:
try:
self.data = toml.load(f)
self.logger.debug("配置文件加载成功: %s", self.path)
except toml.TomlDecodeError as e:
print(f"{e}")
self.logger.error("TOML解析错误: %s", e)
self.data = {}
def modify(self, key: str, value: typing.Any):
"""修改配置值并保存"""
self.data[key] = value
self.logger.debug("修改配置项: %s = %s", key, value)
self.save()
def save(self, path: typing.Union[str, pathlib.Path] = ""):
"""保存配置到文件"""
save_path = pathlib.Path(path) if path else self.path
with open(save_path, "w") as f:
toml.dump(self.data, f)
self.logger.debug("配置文件已保存: %s", save_path)
def get(self, key: str, default: typing.Any = None) -> typing.Any:
"""获取配置值, 如果不存在返回默认值"""
return self.data.get(key, default)
def __getitem__(self, key: str) -> typing.Any:
return self.data[key]
def __setitem__(self, key: str, value: typing.Any):
self.data[key] = value
self.save()
def __contains__(self, key: str) -> bool:
"""支持 in 语法"""
return key in self.data

View File

@@ -0,0 +1,19 @@
# 哈希服务
import hashlib
from heurams.services.logger import get_logger
logger = get_logger(__name__)
def get_md5(text):
logger.debug(f"计算哈希, 输入`{text}`")
result = hashlib.md5(text.encode("utf-8")).hexdigest()
logger.debug("哈希结果: %s...", result[:8])
return result
def hash(text):
logger.debug(f"计算哈希, 输入`{text}`")
result = hashlib.md5(text.encode("utf-8")).hexdigest()
logger.debug("哈希结果: %s...", result[:8])
return result

View File

@@ -0,0 +1,155 @@
"""
HeurAMS 日志服务模块
基于Python标准logging库, 提供统一的日志记录功能
"""
import logging
import logging.handlers
import pathlib
import sys
from typing import Optional, Union
# 默认配置
DEFAULT_LOG_LEVEL = logging.DEBUG
DEFAULT_LOG_FILE = pathlib.Path("heurams.log")
DEFAULT_LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
DEFAULT_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
# 全局logger缓存
_loggers = {}
def setup_logging(
log_file: Union[str, pathlib.Path] = DEFAULT_LOG_FILE,
log_level: int = DEFAULT_LOG_LEVEL,
log_format: str = DEFAULT_LOG_FORMAT,
date_format: str = DEFAULT_DATE_FORMAT,
max_bytes: int = 10 * 1024 * 1024, # 10MB
backup_count: int = 5,
) -> None:
"""
配置全局日志系统
Args:
log_file: 日志文件路径
log_level: 日志级别 (logging.DEBUG, logging.INFO等)
log_format: 日志格式字符串
date_format: 日期时间格式
max_bytes: 单个日志文件最大字节数
backup_count: 备份文件数量
"""
# 确保日志目录存在
log_path = pathlib.Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
# 创建formatter
formatter = logging.Formatter(log_format, date_format)
# 创建文件handler使用RotatingFileHandler防止日志过大
file_handler = logging.handlers.RotatingFileHandler(
filename=log_path,
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
)
file_handler.setFormatter(formatter)
file_handler.setLevel(log_level)
# 配置root logger - 设置为 WARNING 级别(只记录重要信息)
root_logger = logging.getLogger()
root_logger.setLevel(logging.WARNING) # 这里改为 WARNING
# 移除所有现有handler
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 创建自己的应用logger单独设置DEBUG级别
app_logger = logging.getLogger("heurams")
app_logger.setLevel(log_level) # 保持DEBUG级别
app_logger.addHandler(file_handler)
# 禁止传播到root logger, 避免双重记录
app_logger.propagate = False
# 设置第三方库的日志级别为WARNING, 避免调试信息干扰
third_party_loggers = [
"markdown_it",
"markdown_it.rules_block",
"markdown_it.rules_core",
"markdown_it.rules_inline",
"asyncio",
]
for logger_name in third_party_loggers:
logging.getLogger(logger_name).setLevel(logging.WARNING)
# 记录日志系统初始化
app_logger.info("日志系统已初始化, 日志文件: %s", log_path)
def get_logger(name: Optional[str] = None) -> logging.Logger:
"""
获取指定名称的logger
Args:
name: logger名称, 通常使用模块名(__name__)
如果为None, 返回root logger
Returns:
logging.Logger实例
"""
if name is None:
return logging.getLogger()
# 确保使用 heurams 作为前缀, 继承应用logger的配置
if not name.startswith("heurams") and name != "":
logger_name = f"heurams.{name}"
else:
logger_name = name
# 缓存logger以提高性能
if logger_name not in _loggers:
logger = logging.getLogger(logger_name)
_loggers[logger_name] = logger
return _loggers[logger_name]
# 便捷函数
def debug(msg: str, *args, **kwargs) -> None:
"""DEBUG级别日志"""
get_logger().debug(msg, *args, **kwargs)
def info(msg: str, *args, **kwargs) -> None:
"""INFO级别日志"""
get_logger().info(msg, *args, **kwargs)
def warning(msg: str, *args, **kwargs) -> None:
"""WARNING级别日志"""
get_logger().warning(msg, *args, **kwargs)
def error(msg: str, *args, **kwargs) -> None:
"""ERROR级别日志"""
get_logger().error(msg, *args, **kwargs)
def critical(msg: str, *args, **kwargs) -> None:
"""CRITICAL级别日志"""
get_logger().critical(msg, *args, **kwargs)
def exception(msg: str, *args, **kwargs) -> None:
"""记录异常信息 (ERROR级别)"""
get_logger().exception(msg, *args, **kwargs)
# 初始化日志系统(硬编码配置)
setup_logging()
# 模块级别的logger实例
logger = get_logger(__name__)
logger.info("HeurAMS日志服务模块已加载")

View File

@@ -0,0 +1,31 @@
# 时间服务
from heurams.context import config_var
import time
from heurams.services.logger import get_logger
logger = get_logger(__name__)
def get_daystamp() -> int:
"""获取当前日戳(以天为单位的整数时间戳)"""
time_override = config_var.get().get("daystamp_override", -1)
if time_override != -1:
logger.debug("使用覆盖的日戳: %d", time_override)
return int(time_override)
result = int((time.time() + config_var.get().get("timezone_offset")) // (24 * 3600))
logger.debug("计算日戳: %d", result)
return result
def get_timestamp() -> float:
"""获取 UNIX 时间戳"""
# 搞这个类的原因是要支持可复现操作
time_override = config_var.get().get("timestamp_override", -1)
if time_override != -1:
logger.debug("使用覆盖的时间戳: %f", time_override)
return float(time_override)
result = time.time()
logger.debug("获取当前时间戳: %f", result)
return result

View File

@@ -0,0 +1,12 @@
# 文本转语音服务
from heurams.context import config_var
from heurams.providers.tts import TTSs
from typing import Callable
from heurams.services.logger import get_logger
logger = get_logger(__name__)
convert: Callable = TTSs[config_var.get().get("tts_provider")]
logger.debug(
"TTS服务初始化完成, 使用 provider: %s", config_var.get().get("tts_provider")
)

View File

@@ -0,0 +1,10 @@
# 版本控制集成服务
from heurams.services.logger import get_logger
logger = get_logger(__name__)
ver = "0.4.0"
stage = "prototype"
codename = "fledge" # 雏鸟, 0.4.x 版本
logger.info("HeurAMS 版本: %s (%s), 阶段: %s", ver, codename, stage)

View File

@@ -0,0 +1,2 @@
# Utils - 实用工具
脚本与部分分离式工具函数