From 4f054ed2e54e20f64fe63b46b35b1ad1753c4fe4 Mon Sep 17 00:00:00 2001 From: david-ajax Date: Sun, 14 Dec 2025 11:23:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/nucleon/test2.toml | 48 ++++ src/heurams/interface/screens/memorizor.py | 8 +- src/heurams/interface/screens/precache.py | 7 +- src/heurams/interface/screens/synctool.py | 48 ++++ src/heurams/interface/widgets/mcq_puzzle.py | 43 ++-- src/heurams/kernel/puzzles/mcq.py | 236 +++++++++++++++++--- src/heurams/kernel/reactor/procession.py | 1 + 7 files changed, 325 insertions(+), 66 deletions(-) create mode 100644 data/nucleon/test2.toml create mode 100644 src/heurams/interface/screens/synctool.py diff --git a/data/nucleon/test2.toml b/data/nucleon/test2.toml new file mode 100644 index 0000000..f203877 --- /dev/null +++ b/data/nucleon/test2.toml @@ -0,0 +1,48 @@ +# Nucleon 是 HeurAMS 软件项目使用的基于 TOML 的专有源文件格式, 版本 4 +# 建议使用的 MIME 类型: application/vnd.xyz.imwangzhiyu.heurams-nucleon.v4+toml + +["__metadata__"] +["__metadata__.attribution"] # 版权元信息 +author = "__heurams__" +group = "高考古诗文" +name = "过秦论" +license = "CC-BY-SA 4.0" +desc = "高考古诗文 - 过秦论" + +["__metadata__.annotation"] # 键批注 +note = "笔记" +keyword_note = "关键词翻译" +translation = "语句翻译" + +["__metadata__.formation"] # 文件配置 +delimiter = "/" +tts_text = "eval:nucleon['content'].replace('/', '')" + +["__metadata__.orbital.puzzles"] # 谜题定义 +# 我们称 "Recognition" 为 recognition 谜题的 alia +"Recognition" = { __origin__ = "recognition", __hint__ = "", primary = "eval:nucleon['content']", secondary = ["eval:nucleon['keyword_note']", "eval:nucleon['note']"], top_dim = ["eval:nucleon['translation']"] } +"SelectMeaning" = { __origin__ = "mcq", __hint__ = "eval:nucleon['content']", primary = "eval:nucleon['content']", mapping = "eval:nucleon['keyword_note']", jammer = "eval:nucleon['keyword_note']", max_riddles_num = "eval:default['mcq']['max_riddles_num']", prefix = "选择正确项: " } +"FillBlank" = { __origin__ = "cloze", __hint__ = "", text = "eval:nucleon['content']", delimiter = "eval:metadata['formation']['delimiter']", min_denominator = "eval:default['cloze']['min_denominator']"} + +["__metadata__.orbital.schedule"] # 内置的推荐学习方案 +quick_review = [["FillBlank", "1.0"], ["SelectMeaning", "0.5"], ["recognition", "1.0"]] +recognition = [["Recognition", "1.0"]] +final_review = [["FillBlank", "0.7"], ["SelectMeaning", "0.7"], ["recognition", "1.0"]] + +["秦孝公据崤函之固, 拥雍州之地,"] +note = [] +content = "秦孝公/据/崤函/之固/, 拥/雍州/之地,/" +translation = "秦孝公占据着崤山和函谷关的险固地势,拥有雍州的土地," +keyword_note = {"据"="占据", "崤函"="崤山和函谷关", "雍州"="古代九州之一"} + +["君臣固守以窥周室,"] +note = [] +content = "君臣/固守/以窥/周室,/" +translation = "君臣牢固地守卫着,借以窥视周王室的权力," +keyword_note = {"窥"="窥视"} + +["有席卷天下, 包举宇内, 囊括四海之意, 并吞八荒之心."] +note = [] +content = "有/席卷/天下/, 包举/宇内/, 囊括/四海/之意/, 并吞/八荒/之心./" +translation = "有席卷天下,包办天宇之间,囊括四海的意图,并统天下的雄心。" +keyword_note = {"席卷"="像卷席子一样全部卷进去", "包举"="像打包一样全部拿走", "囊括"="像装口袋一样全部装进去", "八荒"="八方荒远之地"} diff --git a/src/heurams/interface/screens/memorizor.py b/src/heurams/interface/screens/memorizor.py index 74f4ecc..c833354 100644 --- a/src/heurams/interface/screens/memorizor.py +++ b/src/heurams/interface/screens/memorizor.py @@ -51,11 +51,11 @@ class MemScreen(Screen): def puzzle_widget(self): try: - print(self.phaser.state) + #print(self.phaser.state) self.fission = Fission(self.procession.current_atom, self.phaser.state) # print(1) puzzle_info = next(self.fission.generate()) - print(puzzle_info) + #print(puzzle_info) return shim.puzzle2widget[puzzle_info["puzzle"]]( atom=self.procession.current_atom, alia=puzzle_info["alia"] ) @@ -90,7 +90,9 @@ class MemScreen(Screen): forwards = 1 if new_rating >= 4 else 0 self.rating = -1 if forwards: - self.procession.forward(1) + ret = self.procession.forward(1) + if ret == 0: + self.procession = self.phaser.current_procession() # type: ignore self.load_puzzle() def action_play_voice(self): diff --git a/src/heurams/interface/screens/precache.py b/src/heurams/interface/screens/precache.py index 950fc3c..225fcc2 100644 --- a/src/heurams/interface/screens/precache.py +++ b/src/heurams/interface/screens/precache.py @@ -4,14 +4,11 @@ from textual.widgets import ( Header, Footer, Label, - Input, - Select, Button, - Markdown, Static, ProgressBar, ) -from textual.containers import Container, Horizontal, Center +from textual.containers import Container, Horizontal from textual.containers import Container from textual.screen import Screen import pathlib @@ -19,7 +16,7 @@ import pathlib import heurams.kernel.particles as pt import heurams.services.hasher as hasher from heurams.context import * -from textual.worker import Worker, get_current_worker +from textual.worker import get_current_worker class PrecachingScreen(Screen): diff --git a/src/heurams/interface/screens/synctool.py b/src/heurams/interface/screens/synctool.py new file mode 100644 index 0000000..c37460d --- /dev/null +++ b/src/heurams/interface/screens/synctool.py @@ -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 Container, Horizontal +from textual.containers import Container +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 Container(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() diff --git a/src/heurams/interface/widgets/mcq_puzzle.py b/src/heurams/interface/widgets/mcq_puzzle.py index 29ec5b3..b098c83 100644 --- a/src/heurams/interface/widgets/mcq_puzzle.py +++ b/src/heurams/interface/widgets/mcq_puzzle.py @@ -1,13 +1,18 @@ +# 单项选择题 from textual.widgets import ( Label, Button, ) +from textual.containers import ( + 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 bidict import bidict +from heurams.services.hasher import hash class Setting(TypedDict): __origin__: str @@ -42,7 +47,7 @@ class MCQPuzzle(BasePuzzleWidget): ) self.inputlist = [] self.alia = alia - self.hashtable = {} + self.hashmap = bidict() self._load() def _load(self): @@ -53,9 +58,7 @@ class MCQPuzzle(BasePuzzleWidget): self.puzzle.refresh() def compose(self): - setting: Setting = self.atom.registry["nucleon"].metadata["orbital"]["puzzle"][ - self.alia - ] + setting: Setting = self.atom.registry["nucleon"].metadata["orbital"]["puzzle"][self.alia] yield Label(setting["primary"], id="sentence") yield Label(self.puzzle.wording[len(self.inputlist)], id="puzzle") yield Label(f"当前输入: {self.inputlist}", id="inputpreview") @@ -63,8 +66,8 @@ class MCQPuzzle(BasePuzzleWidget): # 渲染当前问题的选项 current_options = self.puzzle.options[len(self.inputlist)] for i in current_options: - self.hashtable[str(hash(i))] = i - yield Button(i, id=f"select{hash(i)}") + self.hashmap[str(hash(i))] = i + yield Button(i, id=f"select-{hash(i)}") yield Button("退格", id="delete") @@ -79,12 +82,9 @@ class MCQPuzzle(BasePuzzleWidget): 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": @@ -93,9 +93,10 @@ class MCQPuzzle(BasePuzzleWidget): self.inputlist.pop() self.refresh_buttons() self.update_display() + elif button_id.startswith("select"): # type: ignore # 选项选择处理 - answer_text = self.hashtable[button_id[6:]] # type: ignore + answer_text = self.hashmap[button_id[7:]] # type: ignore self.inputlist.append(answer_text) # 检查是否完成所有题目 @@ -103,16 +104,7 @@ class MCQPuzzle(BasePuzzleWidget): is_correct = self.inputlist == self.puzzle.answer rating = 4 if is_correct else 2 - # 发送完成消息 - self.post_message( - self.PuzzleCompleted( - atom=self.atom, - rating=rating, - is_correct=is_correct, - user_answers=self.inputlist.copy(), - correct_answers=self.puzzle.answer.copy(), - ) - ) + self.screen.rating = rating # type: ignore # 重置输入(如果回答错误) if not is_correct: @@ -132,6 +124,7 @@ class MCQPuzzle(BasePuzzleWidget): for child in self.children if hasattr(child, "id") and child.id and child.id.startswith("select") ] + for button in buttons_to_remove: self.remove_child(button) # type: ignore @@ -140,8 +133,8 @@ class MCQPuzzle(BasePuzzleWidget): if current_question_index < len(self.puzzle.options): current_options = self.puzzle.options[current_question_index] for option in current_options: - button_id = f"select{hash(option)}" - if button_id not in self.hashtable: - self.hashtable[button_id] = option + button_id = f"select-{hash(option)}" + if button_id not in self.hashmap: + self.hashmap[button_id] = option new_button = Button(option, id=button_id) self.mount(new_button) diff --git a/src/heurams/kernel/puzzles/mcq.py b/src/heurams/kernel/puzzles/mcq.py index 1e1e4dc..a35ae78 100644 --- a/src/heurams/kernel/puzzles/mcq.py +++ b/src/heurams/kernel/puzzles/mcq.py @@ -1,58 +1,228 @@ # mcq.py from .base import BasePuzzle import random +from typing import List, Dict, Optional, Union 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, jammer: list, max_riddles_num: int = 2, prefix: str = "" - ): + 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: 题目前缀文本, 会显示在每个题目之前 + """ self.prefix = prefix self.mapping = mapping - self.jammer = list(set(jammer + list(mapping.values()))) + 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: 传入的干扰项列表 + """ + # 合并正确答案和传入的干扰项, 并去重 + unique_jammers = set(jammer + list(self.mapping.values())) + self.jammer = list(unique_jammers) + + # 确保至少有4个干扰项 while len(self.jammer) < 4: self.jammer.append(" ") - self.max_riddles_num = max(1, min(max_riddles_num, 5)) - self.wording = "选择题 - 尚未刷新谜题" - self.answer = ["选择题 - 尚未刷新谜题"] - self.options = [] - def refresh(self): - """刷新谜题,根据题目数量生成适当数量的谜题""" + 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为空时不会抛出异常, 但会设置空谜题状态 + """ if not self.mapping: - self.wording = "无可用题目" - self.answer = ["无答案"] - self.options = [] + self._set_empty_puzzle() return num_questions = min(self.max_riddles_num, len(self.mapping)) - questions = random.sample(list(self.mapping.items()), num_questions) - puzzles = [] - answers = [] - all_options = [] + selected_questions = random.sample(list(self.mapping.items()), num_questions) - for question, correct_answer in questions: - options = [correct_answer] - available_jammers = [j for j in self.jammer if j != correct_answer] - 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) + 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) - question_texts = [] - for i, puzzle in enumerate(puzzles): - question_texts.append(f"{self.prefix}:\n {i+1}. {puzzle}") - - self.wording = question_texts + self.wording = self._format_questions(puzzles) self.answer = answers self.options = all_options - def __str__(self): - return f"{self.wording}\n正确答案: {', '.join(self.answer)}" + 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 diff --git a/src/heurams/kernel/reactor/procession.py b/src/heurams/kernel/reactor/procession.py index f28a71d..b76a606 100644 --- a/src/heurams/kernel/reactor/procession.py +++ b/src/heurams/kernel/reactor/procession.py @@ -25,6 +25,7 @@ class Procession: return 1 # 成功 except IndexError as e: print(f"{e}") + self.state = ProcessionState.FINISHED return 0 def append(self, atom=None):