muninn-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: muninn-cli
3
+ Version: 0.1.1
4
+ Summary: Muninn - The Extensible Reciting CLI
5
+ Project-URL: Repository, https://github.com/a1fredbao/muninn
6
+ Project-URL: Issues, https://github.com/a1fredbao/muninn/issues
7
+ Author-email: Alfred <your@email.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,flashcard,memorize,recite
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Education
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Muninn
22
+
23
+ Muninn (雾尼) - An Extensible Reciting CLI.
24
+
25
+ [中文文档](./README_zh.md)
26
+
27
+ ## What is Muninn?
28
+
29
+ Muninn is a highly extensible CLI application designed to help you memorize anything. Instead of hardcoding questions, Muninn relies on a **Plugin Architecture**. You can install "Reciting Packs" created by others (like chemistry elements, GRE vocabulary, or historical events) or develop your own packs using Python.
30
+
31
+ Muninn acts as a "host" that provides:
32
+
33
+ 1. A **Smart Scheduling Algorithm** (focuses on your weak-points).
34
+ 2. **Persistent State Management** (remembers your progress across sessions).
35
+ 3. A clean, distraction-free **Terminal UI**.
36
+
37
+ ## Installation & Usage
38
+
39
+ Install Muninn globally using `uv` (recommended) or `pip`:
40
+
41
+ ```bash
42
+ # Using uv (Recommended)
43
+ uv tool install muninn-cli
44
+
45
+ # Or using pip
46
+ pip install muninn-cli
47
+ ```
48
+
49
+ Available commands:
50
+
51
+ ```bash
52
+ # List all installed packs
53
+ muninn list
54
+
55
+ # Install a pack (supports directories or .zip files)
56
+ muninn install path/to/pack_or_zip
57
+
58
+ # Uninstall a previously installed pack
59
+ muninn uninstall <pack_id>
60
+
61
+ # Run a specific pack by ID
62
+ muninn run <pack_id>
63
+
64
+ # Generate a new plugin template for development
65
+ muninn new <your_new_pack_id>
66
+ ```
67
+
68
+ ## Plugin Development Guide
69
+
70
+ Muninn provides a layered API. Choose the level that fits your needs.
71
+
72
+ ### Quickstart: `FlashcardPlugin` (zero boilerplate)
73
+
74
+ For simple front/back flashcards (e.g. GRE words), create a CSV or JSON file with `front` and `back` columns, then write a 3-line plugin:
75
+
76
+ ```python
77
+ from core.helpers import FlashcardPlugin
78
+
79
+
80
+ class Plugin(FlashcardPlugin):
81
+ DATA_FILE = "words.csv"
82
+ ```
83
+
84
+ That's it. `FlashcardPlugin` handles rendering, answer-checking, and ID generation automatically.
85
+
86
+ | File | Description |
87
+ |------|-------------|
88
+ | `manifest.json` | Pack metadata (name, author, version). |
89
+ | `words.csv` | Data with `front` and `back` columns. |
90
+ | `plugin.py` | The 3-line plugin above. |
91
+
92
+ ### For structured data: `DataPlugin` + `QuestionType`
93
+
94
+ When each data record can be quizzed from multiple angles, use `DataPlugin`. Declare your **question types** and let Muninn generate all problem variants automatically.
95
+
96
+ **Example** — Chemistry elements quizzed from 4 directions (symbol → name, name → number, etc.):
97
+
98
+ ```python
99
+ import os, json
100
+ from core.helpers import DataPlugin, QuestionType, Matchers
101
+
102
+
103
+ class Plugin(DataPlugin):
104
+ QUESTION_TYPES = [
105
+ QuestionType(
106
+ label="看序号背元素",
107
+ statement=lambda el: f"原子序数: {el['num']}",
108
+ answer=lambda el: f"{el['name']} {el['sym']}",
109
+ matcher=Matchers.chinese_symbol_pair("name", "sym"),
110
+ ),
111
+ QuestionType(
112
+ label="看元素背序号",
113
+ statement=lambda el: f"元素: {el['name']} ({el['sym']})",
114
+ answer=lambda el: str(el["num"]),
115
+ matcher=Matchers.exact_integer("num"),
116
+ ),
117
+ ]
118
+
119
+ def load_records(self) -> list:
120
+ with open(
121
+ os.path.join(self.workspace_dir, "elements.json"), encoding="utf-8"
122
+ ) as f:
123
+ return json.load(f)
124
+
125
+ def filter(self, record, q_type):
126
+ # Optional: skip certain question types for specific records
127
+ return True
128
+ ```
129
+
130
+ `DataPlugin` auto-generates problem IDs (`{record_index}__{question_label}`) and routes all five interface methods. You only supply data + question types.
131
+
132
+ ### Built-in Matchers
133
+
134
+ Instead of writing custom regex for every question type, use the built-in `Matchers` factories:
135
+
136
+ | Matcher | Behavior |
137
+ |---------|----------|
138
+ | `Matchers.exact(key)` | Exact match after trimming whitespace. |
139
+ | `Matchers.exact_integer(key)` | Extract digits, compare numerically. |
140
+ | `Matchers.case_insensitive(key)` | Case-insensitive match. |
141
+ | `Matchers.chinese_symbol_pair(key1, key2)` | Match "中文+符号" or "符号+中文" in any order. |
142
+ | `Matchers.any_order(*keys)` | Match all field values appearing anywhere in the input. |
143
+ | `Matchers.custom(fn)` | Pass your own `(record, user_input) -> bool` function. |
144
+
145
+ ### Low-level: `BaseRecitePlugin`
146
+
147
+ For full control, implement the base interface directly:
148
+
149
+ ```python
150
+ from core.base_plugin import BaseRecitePlugin
151
+
152
+
153
+ class Plugin(BaseRecitePlugin):
154
+ def load_data(self):
155
+ # Load static data from self.workspace_dir
156
+ pass
157
+
158
+ def get_all_problem_ids(self) -> list[str]:
159
+ """Return all unique problem IDs."""
160
+ pass
161
+
162
+ def render_statement(self, problem_id: str) -> str:
163
+ """Return the question text to display."""
164
+ pass
165
+
166
+ def check_answer(self, problem_id: str, user_input: str) -> bool:
167
+ """Return True if correct."""
168
+ pass
169
+
170
+ def get_expected_display(self, problem_id: str) -> str:
171
+ """Return the correct answer to show on failure."""
172
+ pass
173
+
174
+ def get_expand_info(self, problem_id: str) -> str:
175
+ """Optional: Return extra info to show on success."""
176
+ return ""
177
+ ```
178
+
179
+ ### Developing a pack from scratch
180
+
181
+ 1. **Generate a template**:
182
+
183
+ ```bash
184
+ muninn new my_cool_pack
185
+ ```
186
+
187
+ This creates a `my_cool_pack/` directory with `manifest.json` and a skeleton `plugin.py`.
188
+
189
+ 2. **Write your logic** using one of the approaches above.
190
+
191
+ 3. **Install and test**:
192
+
193
+ ```bash
194
+ muninn install ./my_cool_pack
195
+ muninn run my_cool_pack
196
+ ```
@@ -0,0 +1,16 @@
1
+ src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ src/main.py,sha256=jDfnAF8WVUtOz1TshANchnWoNNi9_6pPt8dJQ2U11uU,2925
3
+ src/ui.py,sha256=uP6qx9mmBvt6XUaO3zThEvZ3gucHh-qUuHgrmHaYHOE,1168
4
+ src/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ src/cli/manager.py,sha256=b9fte4QX1P-9y1LNuDbN7-4NZShFHHRoo-4Lcaq3OxI,5787
6
+ src/cli/runner.py,sha256=olEke0H7ZheZQs7EwiBLq-Qm7vOvyncFVevjA4Nw868,3872
7
+ src/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ src/core/base_plugin.py,sha256=J1TDlkfNZq-illtHruld4jakOzK2gdkLrGMPRfAxtoM,1214
9
+ src/core/helpers.py,sha256=TN7nLTm8sQs6Ep9qYwG_H3qmX-HJY1xZamkB7Lo-qaE,7180
10
+ src/core/scheduler.py,sha256=57G6fKK7QfveIY0QM1-b1_KmH67FgGtb76dcGvVgS2Y,1675
11
+ src/core/state.py,sha256=LMMb8Uj81tbt_LyHTUHQuxSNvhgzkGxU1TwK7fWyC7c,2185
12
+ muninn_cli-0.1.1.dist-info/METADATA,sha256=uKsmryWAKS2X9mzqZ6Xwb8SvlUeOb_Jvg0b90rxyGrw,5996
13
+ muninn_cli-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ muninn_cli-0.1.1.dist-info/entry_points.txt,sha256=IUx54nFj9LxkwOnMCH6Gcx6HruFkKGL7mJerkDTjRrg,41
15
+ muninn_cli-0.1.1.dist-info/licenses/LICENSE,sha256=8EOHZ_sSvknlRG8QxCYLwgsfj_Uo3ZqljHadd-2kK0I,1067
16
+ muninn_cli-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ muninn = src.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alfred Bao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
src/__init__.py ADDED
File without changes
src/cli/__init__.py ADDED
File without changes
src/cli/manager.py ADDED
@@ -0,0 +1,161 @@
1
+ import importlib.util
2
+ import json
3
+ import os
4
+ import shutil
5
+ import sys
6
+ import zipfile
7
+
8
+ from ..core.base_plugin import BaseRecitePlugin
9
+
10
+
11
+ class PackageManager:
12
+ def __init__(self):
13
+ self.packs_dir = os.path.expanduser("~/.muninn/packs")
14
+ os.makedirs(self.packs_dir, exist_ok=True)
15
+
16
+ def _get_pack_dir(self, pack_id: str) -> str:
17
+ return os.path.join(self.packs_dir, pack_id)
18
+
19
+ def create_template(self, pack_id: str, target_dir: str = "."):
20
+ """Generate a new plugin template."""
21
+ pack_dir = os.path.join(target_dir, pack_id)
22
+ if os.path.exists(pack_dir):
23
+ raise FileExistsError(f"Directory {pack_dir} already exists.")
24
+
25
+ os.makedirs(pack_dir)
26
+
27
+ manifest = {
28
+ "id": pack_id,
29
+ "name": f"{pack_id} Pack",
30
+ "author": "Your Name",
31
+ "version": "1.0.0",
32
+ "description": "A new reciting pack for Muninn.",
33
+ }
34
+ with open(os.path.join(pack_dir, "manifest.json"), "w", encoding="utf-8") as f:
35
+ json.dump(manifest, f, indent=4, ensure_ascii=False)
36
+
37
+ plugin_code = '''"""A minimal Muninn plugin using DataPlugin.
38
+
39
+ For flashcard-style packs (front/back), use FlashcardPlugin instead.
40
+ For full control, use the BaseRecitePlugin interface directly.
41
+ """
42
+ from typing import ClassVar
43
+
44
+ from core.helpers import (
45
+ DataPlugin,
46
+ Matchers,
47
+ QuestionType,
48
+ )
49
+
50
+
51
+ class Plugin(DataPlugin):
52
+ QUESTION_TYPES: ClassVar[list[QuestionType]] = [
53
+ # TODO: Define your question types here.
54
+ # Each QuestionType needs a label, statement, answer, and matcher.
55
+ # See the documentation for examples.
56
+ ]
57
+ '''
58
+ with open(os.path.join(pack_dir, "plugin.py"), "w", encoding="utf-8") as f:
59
+ f.write(plugin_code)
60
+
61
+ print(f"✅ Template created at {pack_dir}")
62
+
63
+ def install_pack(self, source_path: str) -> str:
64
+ """Install a pack from a directory or zip file."""
65
+ if not os.path.exists(source_path):
66
+ raise FileNotFoundError(f"Path not found: {source_path}")
67
+
68
+ temp_dir = os.path.join(self.packs_dir, ".temp_install")
69
+ if os.path.exists(temp_dir):
70
+ shutil.rmtree(temp_dir)
71
+
72
+ if os.path.isdir(source_path):
73
+ shutil.copytree(source_path, temp_dir)
74
+ elif zipfile.is_zipfile(source_path):
75
+ with zipfile.ZipFile(source_path, "r") as zip_ref:
76
+ zip_ref.extractall(temp_dir)
77
+ else:
78
+ raise ValueError(
79
+ "Unsupported file format. Must be a directory or zip file."
80
+ )
81
+
82
+ # Find manifest.json
83
+ manifest_path = os.path.join(temp_dir, "manifest.json")
84
+ if not os.path.exists(manifest_path):
85
+ shutil.rmtree(temp_dir)
86
+ raise FileNotFoundError("manifest.json not found in the package.")
87
+
88
+ with open(manifest_path, "r", encoding="utf-8") as f:
89
+ manifest = json.load(f)
90
+
91
+ pack_id = manifest.get("id")
92
+ if not pack_id:
93
+ shutil.rmtree(temp_dir)
94
+ raise ValueError("Invalid manifest: missing 'id'")
95
+
96
+ target_dir = self._get_pack_dir(pack_id)
97
+ if os.path.exists(target_dir):
98
+ print(f"🔄 Updating existing pack: {pack_id}")
99
+ shutil.rmtree(target_dir)
100
+
101
+ shutil.move(temp_dir, target_dir)
102
+ print(f"✅ Successfully installed pack '{pack_id}'")
103
+ return pack_id
104
+
105
+ def uninstall_pack(self, pack_id: str):
106
+ """Remove a pack from the local packs directory."""
107
+ pack_dir = self._get_pack_dir(pack_id)
108
+ if not os.path.exists(pack_dir):
109
+ raise FileNotFoundError(f"Pack '{pack_id}' is not installed.")
110
+ shutil.rmtree(pack_dir)
111
+ print(f"🗑️ Pack '{pack_id}' has been uninstalled.")
112
+
113
+ def load_plugin(self, pack_id: str) -> BaseRecitePlugin:
114
+ """Dynamically load the plugin.py from the pack_id directory."""
115
+ pack_dir = self._get_pack_dir(pack_id)
116
+ if not os.path.exists(pack_dir):
117
+ raise FileNotFoundError(f"Pack '{pack_id}' not found.")
118
+
119
+ plugin_path = os.path.join(pack_dir, "plugin.py")
120
+ if not os.path.exists(plugin_path):
121
+ raise FileNotFoundError(f"plugin.py not found in {pack_id}.")
122
+
123
+ src_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
124
+ sys.path.insert(0, src_dir)
125
+
126
+ try:
127
+ spec = importlib.util.spec_from_file_location(
128
+ f"muninn.plugins.{pack_id}", plugin_path
129
+ )
130
+ module = importlib.util.module_from_spec(spec)
131
+ spec.loader.exec_module(module)
132
+
133
+ # Find the class that inherits from BaseRecitePlugin
134
+ for attr_name in dir(module):
135
+ attr = getattr(module, attr_name)
136
+ if (
137
+ isinstance(attr, type)
138
+ and attr.__module__ == module.__name__
139
+ and any(b.__name__ == "BaseRecitePlugin" for b in attr.__mro__)
140
+ and attr.__name__ != "BaseRecitePlugin"
141
+ ):
142
+ return attr(workspace_dir=pack_dir)
143
+
144
+ raise ValueError(
145
+ f"No valid BaseRecitePlugin subclass found in {plugin_path}"
146
+ )
147
+ finally:
148
+ sys.path.pop(0)
149
+
150
+ def list_packs(self):
151
+ packs = []
152
+ for pack_id in os.listdir(self.packs_dir):
153
+ pack_dir = self._get_pack_dir(pack_id)
154
+ if not os.path.isdir(pack_dir):
155
+ continue
156
+ manifest_path = os.path.join(pack_dir, "manifest.json")
157
+ if os.path.exists(manifest_path):
158
+ with open(manifest_path, "r", encoding="utf-8") as f:
159
+ manifest = json.load(f)
160
+ packs.append(manifest)
161
+ return packs
src/cli/runner.py ADDED
@@ -0,0 +1,121 @@
1
+ import sys
2
+ import time
3
+
4
+ from ..core.base_plugin import BaseRecitePlugin
5
+ from ..core.scheduler import Scheduler
6
+ from ..core.state import StateManager
7
+ from ..ui import TerminalUI
8
+
9
+
10
+ class GameRunner:
11
+ def __init__(self, pack_id: str, plugin: BaseRecitePlugin):
12
+ self.pack_id = pack_id
13
+ self.plugin = plugin
14
+
15
+ self.problem_ids = self.plugin.get_all_problem_ids()
16
+ self.state_manager = StateManager(pack_id)
17
+ self.scheduler = Scheduler(self.problem_ids, self.state_manager)
18
+
19
+ self.combo = 0
20
+
21
+ # Calculate distinct AC count across all problems at startup
22
+ self.distinct_ac = 0
23
+ self.total_ac_count = 0
24
+ self.total_count = 0
25
+ self.total_ac_time = 0.0
26
+
27
+ for pid in self.problem_ids:
28
+ stats = self.state_manager.get_stats(pid)
29
+ if stats["ac_count"] > 0:
30
+ self.distinct_ac += 1
31
+ self.total_ac_count += stats["ac_count"]
32
+ self.total_count += stats["total_count"]
33
+ self.total_ac_time += stats["total_ac_time"]
34
+
35
+ def run(self):
36
+ TerminalUI.clear_screen()
37
+ TerminalUI.print_green(f"====== 正在学习包: {self.pack_id} ======\n")
38
+ TerminalUI.wait_any_key()
39
+
40
+ try:
41
+ while True:
42
+ problem_id = self.scheduler.next_problem()
43
+ if not problem_id:
44
+ print("题库为空!")
45
+ break
46
+ self.ask(problem_id)
47
+ except (KeyboardInterrupt, EOFError):
48
+ self.quit()
49
+
50
+ def ask(self, problem_id: str):
51
+ TerminalUI.clear_screen()
52
+
53
+ avg_time = (
54
+ self.total_ac_time / self.total_ac_count if self.total_ac_count > 0 else 0
55
+ )
56
+ TerminalUI.print_stats_banner(
57
+ distinct_ac=self.distinct_ac,
58
+ total_problems=len(self.problem_ids),
59
+ ac_count=self.total_ac_count,
60
+ total_count=self.total_count,
61
+ combo=self.combo,
62
+ avg_time=avg_time,
63
+ )
64
+
65
+ statement = self.plugin.render_statement(problem_id)
66
+ TerminalUI.print_cyan(statement)
67
+ print("(输入 q 退出)")
68
+
69
+ start_time = time.perf_counter()
70
+ user_input = input(">> ").strip()
71
+
72
+ if user_input.lower() == "q":
73
+ self.quit()
74
+
75
+ end_time = time.perf_counter()
76
+ time_spent = end_time - start_time
77
+
78
+ self.total_count += 1
79
+
80
+ is_correct = self.plugin.check_answer(problem_id, user_input)
81
+
82
+ if is_correct:
83
+ self._handle_ac(problem_id, time_spent)
84
+ else:
85
+ self._handle_wa(problem_id)
86
+
87
+ self.scheduler.update_problem(problem_id, is_correct, time_spent)
88
+
89
+ def _handle_ac(self, problem_id: str, time_spent: float):
90
+ stats = self.state_manager.get_stats(problem_id)
91
+ if stats["ac_count"] == 0:
92
+ self.distinct_ac += 1
93
+
94
+ self.total_ac_count += 1
95
+ self.combo += 1
96
+ self.total_ac_time += time_spent
97
+
98
+ TerminalUI.print_success_banner(time_spent)
99
+ expand_info = self.plugin.get_expand_info(problem_id)
100
+ if expand_info:
101
+ print(f"💡 拓展信息: {expand_info}\n")
102
+
103
+ TerminalUI.wait_any_key()
104
+
105
+ def _handle_wa(self, problem_id: str):
106
+ self.combo = 0
107
+ TerminalUI.print_error_banner()
108
+ expected = self.plugin.get_expected_display(problem_id)
109
+ print(f"标准答案: \033[33;1m{expected}\033[0m\n")
110
+ TerminalUI.wait_any_key()
111
+
112
+ def quit(self):
113
+ TerminalUI.clear_screen()
114
+ print("=== 学习统计 ===")
115
+ print(f"总答题数: {self.total_count}")
116
+ if self.total_count > 0:
117
+ print(
118
+ f"总正确率: {self.total_ac_count} / {self.total_count} ({self.total_ac_count / self.total_count * 100:.1f}%)"
119
+ )
120
+ self.state_manager.close()
121
+ sys.exit(0)
src/core/__init__.py ADDED
File without changes
@@ -0,0 +1,32 @@
1
+ class BaseRecitePlugin:
2
+ def __init__(self, workspace_dir: str):
3
+ """
4
+ 初始化插件
5
+ :param workspace_dir: CLI 分配给该包的私有静态数据目录
6
+ """
7
+ self.workspace_dir = workspace_dir
8
+ self.load_data()
9
+
10
+ def load_data(self):
11
+ """加载 workspace_dir 中的静态数据,由子类实现"""
12
+ raise NotImplementedError
13
+
14
+ def get_all_problem_ids(self) -> list[str]:
15
+ """返回题库中所有题目的唯一 ID,供 CLI 调度器建立索引和存档进度"""
16
+ raise NotImplementedError
17
+
18
+ def render_statement(self, problem_id: str) -> str:
19
+ """根据题目 ID 返回要在终端显示的问题文本"""
20
+ raise NotImplementedError
21
+
22
+ def check_answer(self, problem_id: str, user_input: str) -> bool:
23
+ """回调函数:判断用户输入是否正确"""
24
+ raise NotImplementedError
25
+
26
+ def get_expected_display(self, problem_id: str) -> str:
27
+ """回答错误时,展示给用户的标准答案"""
28
+ raise NotImplementedError
29
+
30
+ def get_expand_info(self, problem_id: str) -> str:
31
+ """回答正确时,展示给用户的拓展/提示信息 (可选)"""
32
+ return ""
src/core/helpers.py ADDED
@@ -0,0 +1,205 @@
1
+ import csv
2
+ import json
3
+ import os
4
+ import re
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any, ClassVar
8
+
9
+ from .base_plugin import BaseRecitePlugin
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Matchers – factory functions for reusable answer-checking logic
13
+ # ---------------------------------------------------------------------------
14
+
15
+
16
+ class Matchers:
17
+ """Built-in matcher factories.
18
+
19
+ Each classmethod returns a ``(data_item: dict, user_input: str) -> bool``
20
+ callable, suitable for use as a ``QuestionType.matcher``.
21
+ """
22
+
23
+ @staticmethod
24
+ def exact(key: str):
25
+ """Exact match after stripping whitespace."""
26
+
27
+ def match(data_item: dict, user_input: str) -> bool:
28
+ return user_input.strip() == str(data_item[key]).strip()
29
+
30
+ return match
31
+
32
+ @staticmethod
33
+ def exact_integer(key: str):
34
+ """Extract digits from the user input and compare numerically."""
35
+
36
+ def match(data_item: dict, user_input: str) -> bool:
37
+ digits = re.sub(r"\D", "", user_input)
38
+ return digits == str(data_item[key])
39
+
40
+ return match
41
+
42
+ @staticmethod
43
+ def case_insensitive(key: str):
44
+ """Case-insensitive match after stripping whitespace."""
45
+
46
+ def match(data_item: dict, user_input: str) -> bool:
47
+ return user_input.strip().lower() == str(data_item[key]).strip().lower()
48
+
49
+ return match
50
+
51
+ @staticmethod
52
+ def chinese_symbol_pair(key1: str, key2: str):
53
+ """Match "中文+符号" or "符号+中文" in either order, ignoring
54
+ whitespace and case (for the Latin part)."""
55
+
56
+ def match(data_item: dict, user_input: str) -> bool:
57
+ val1 = str(data_item[key1]).strip()
58
+ val2 = str(data_item[key2]).strip()
59
+ clean = re.sub(r"\s+", "", user_input).lower()
60
+ return clean in ((val1 + val2).lower(), (val2 + val1).lower())
61
+
62
+ return match
63
+
64
+ @staticmethod
65
+ def any_order(*keys: str):
66
+ """Match when all field values appear somewhere in the input,
67
+ ignoring non-alphanumeric characters and case."""
68
+
69
+ def match(data_item: dict, user_input: str) -> bool:
70
+ clean_input = re.sub(r"[^a-zA-Z0-9]", "", user_input).upper()
71
+ values = [
72
+ re.sub(r"[^a-zA-Z0-9]", "", str(data_item[k])).upper() for k in keys
73
+ ]
74
+ return all(v in clean_input for v in values)
75
+
76
+ return match
77
+
78
+ @staticmethod
79
+ def custom(fn: Callable[[dict, str], bool]):
80
+ """Pass-through for a fully custom matcher function."""
81
+ return fn
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # QuestionType – a reusable question "direction"
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ @dataclass
90
+ class QuestionType:
91
+ """Encapsulates one question direction: how to render the statement,
92
+ how to render the expected answer, and how to check correctness."""
93
+
94
+ label: str
95
+ statement: Callable[[dict], str]
96
+ answer: Callable[[dict], str]
97
+ matcher: Callable[[dict, str], bool]
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # DataPlugin – base class for record × QuestionType plugins
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ class DataPlugin(BaseRecitePlugin):
106
+ """Higher-level plugin for "entity + multi-question-direction" scenarios.
107
+
108
+ Subclasses supply:
109
+ - ``QUESTION_TYPES``: a list of ``QuestionType`` instances.
110
+ - ``load_records()``: returns a list of data dicts.
111
+ - (optional) ``filter(record, q_type)``: return False to skip a
112
+ particular record × question-type combination.
113
+
114
+ ``DataPlugin`` auto-generates problem IDs, routes all five abstract
115
+ methods, and exposes ``_resolve(problem_id) -> (record, q_type)`` for
116
+ subclasses that need custom ``get_expand_info`` or similar overrides.
117
+ """
118
+
119
+ QUESTION_TYPES: ClassVar[list[QuestionType]] = []
120
+
121
+ def load_data(self) -> None:
122
+ self._records = self.load_records()
123
+ self._problem_map: dict[str, tuple[dict, QuestionType]] = {}
124
+ for i, record in enumerate(self._records):
125
+ for qt in self.QUESTION_TYPES:
126
+ if self.filter(record, qt):
127
+ pid = f"{i}__{qt.label}"
128
+ self._problem_map[pid] = (record, qt)
129
+
130
+ def load_records(self) -> list[dict[str, Any]]:
131
+ """Override to return a list of data records from workspace_dir."""
132
+ raise NotImplementedError
133
+
134
+ def filter(self, record: dict, q_type: QuestionType) -> bool:
135
+ """Override to exclude some record × question-type combos."""
136
+ return True
137
+
138
+ def _resolve(self, problem_id: str) -> tuple[dict, QuestionType]:
139
+ return self._problem_map[problem_id]
140
+
141
+ # -- BaseRecitePlugin interface -----------------------------------------
142
+
143
+ def get_all_problem_ids(self) -> list[str]:
144
+ return list(self._problem_map.keys())
145
+
146
+ def render_statement(self, problem_id: str) -> str:
147
+ record, qt = self._resolve(problem_id)
148
+ return f"【{qt.label}】 {qt.statement(record)}"
149
+
150
+ def check_answer(self, problem_id: str, user_input: str) -> bool:
151
+ record, qt = self._resolve(problem_id)
152
+ return qt.matcher(record, user_input.strip())
153
+
154
+ def get_expected_display(self, problem_id: str) -> str:
155
+ record, qt = self._resolve(problem_id)
156
+ return qt.answer(record)
157
+
158
+ def get_expand_info(self, problem_id: str) -> str:
159
+ return ""
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # FlashcardPlugin – zero-boilerplate front/back flashcard
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ class FlashcardPlugin(DataPlugin):
168
+ """Plug-and-play flashcard-style plugin.
169
+
170
+ Point ``DATA_FILE`` at a CSV (columns ``front``, ``back``) or JSON
171
+ (list of ``{"front": ..., "back": ...}`` objects) in the workspace
172
+ directory. All five plugin methods are handled automatically.
173
+
174
+ Example::
175
+
176
+ class Plugin(FlashcardPlugin):
177
+ DATA_FILE = "words.csv"
178
+ """
179
+
180
+ DATA_FILE: str = ""
181
+
182
+ def load_data(self) -> None:
183
+ path = os.path.join(self.workspace_dir, self.DATA_FILE)
184
+ if path.endswith(".csv"):
185
+ with open(path, encoding="utf-8", newline="") as f:
186
+ self._records = list(csv.DictReader(f))
187
+ elif path.endswith(".json"):
188
+ with open(path, encoding="utf-8") as f:
189
+ self._records = json.load(f)
190
+ else:
191
+ raise ValueError(
192
+ f"Unsupported DATA_FILE format: {self.DATA_FILE!r}. "
193
+ "Expected .csv or .json"
194
+ )
195
+
196
+ qt = QuestionType(
197
+ label="闪卡",
198
+ statement=lambda r: str(r.get("front", "")),
199
+ answer=lambda r: str(r.get("back", "")),
200
+ matcher=Matchers.exact("back"),
201
+ )
202
+
203
+ self._problem_map: dict[str, tuple[dict, QuestionType]] = {}
204
+ for i, record in enumerate(self._records):
205
+ self._problem_map[str(i)] = (record, qt)
src/core/scheduler.py ADDED
@@ -0,0 +1,44 @@
1
+ import heapq
2
+ import random
3
+
4
+ from .state import StateManager
5
+
6
+
7
+ class Scheduler:
8
+ def __init__(self, problem_ids: list[str], state_manager: StateManager):
9
+ self.problem_ids = problem_ids
10
+ self.state_manager = state_manager
11
+ self.q_queue = []
12
+ self._init_queue()
13
+
14
+ def _calculate_weight(self, problem_id: str) -> float:
15
+ stats = self.state_manager.get_stats(problem_id)
16
+ ac_count = stats["ac_count"]
17
+ total_count = stats["total_count"]
18
+ total_ac_time = stats["total_ac_time"]
19
+
20
+ ac_ratio = ac_count / total_count if total_count > 0 else 0
21
+ avg_time = total_ac_time / ac_count if ac_count > 0 else 10.0
22
+
23
+ # Smart weight: higher ac_count & ratio -> lower weight (less likely)
24
+ # Higher avg_time -> higher weight
25
+ return random.random() - ac_count - 10.0 * ac_ratio + avg_time
26
+
27
+ def _init_queue(self):
28
+ for pid in self.problem_ids:
29
+ weight = self._calculate_weight(pid)
30
+ # heapq is a min-heap, so we push negative weight to pop the max weight
31
+ heapq.heappush(self.q_queue, (-weight, pid))
32
+
33
+ def next_problem(self) -> str:
34
+ """Returns the ID of the next problem to display."""
35
+ if not self.q_queue:
36
+ return None
37
+ _, pid = heapq.heappop(self.q_queue)
38
+ return pid
39
+
40
+ def update_problem(self, problem_id: str, is_ac: bool, time_spent: float):
41
+ """Updates the state and pushes the problem back into the queue."""
42
+ self.state_manager.update_stats(problem_id, is_ac, time_spent)
43
+ weight = self._calculate_weight(problem_id)
44
+ heapq.heappush(self.q_queue, (-weight, problem_id))
src/core/state.py ADDED
@@ -0,0 +1,62 @@
1
+ import os
2
+ import sqlite3
3
+ from typing import Any
4
+
5
+
6
+ class StateManager:
7
+ def __init__(self, pack_id: str):
8
+ self.pack_id = pack_id
9
+ # ~/.muninn/states/
10
+ self.state_dir = os.path.expanduser(os.path.join("~", ".muninn", "states"))
11
+ os.makedirs(self.state_dir, exist_ok=True)
12
+
13
+ self.db_path = os.path.join(self.state_dir, f"{pack_id}.db")
14
+ self.conn = sqlite3.connect(self.db_path)
15
+ self._init_db()
16
+
17
+ def _init_db(self):
18
+ cursor = self.conn.cursor()
19
+ cursor.execute("""
20
+ CREATE TABLE IF NOT EXISTS problem_stats (
21
+ problem_id TEXT PRIMARY KEY,
22
+ ac_count INTEGER DEFAULT 0,
23
+ total_count INTEGER DEFAULT 0,
24
+ total_ac_time REAL DEFAULT 0.0
25
+ )
26
+ """)
27
+ self.conn.commit()
28
+
29
+ def get_stats(self, problem_id: str) -> dict[str, Any]:
30
+ cursor = self.conn.cursor()
31
+ cursor.execute(
32
+ "SELECT ac_count, total_count, total_ac_time FROM problem_stats WHERE problem_id = ?",
33
+ (problem_id,),
34
+ )
35
+ row = cursor.fetchone()
36
+ if row:
37
+ return {"ac_count": row[0], "total_count": row[1], "total_ac_time": row[2]}
38
+ return {"ac_count": 0, "total_count": 0, "total_ac_time": 0.0}
39
+
40
+ def update_stats(self, problem_id: str, is_ac: bool, time_spent: float):
41
+ stats = self.get_stats(problem_id)
42
+
43
+ new_total_count = stats["total_count"] + 1
44
+ new_ac_count = stats["ac_count"] + (1 if is_ac else 0)
45
+ new_total_ac_time = stats["total_ac_time"] + (time_spent if is_ac else 0.0)
46
+
47
+ cursor = self.conn.cursor()
48
+ cursor.execute(
49
+ """
50
+ INSERT INTO problem_stats (problem_id, ac_count, total_count, total_ac_time)
51
+ VALUES (?, ?, ?, ?)
52
+ ON CONFLICT(problem_id) DO UPDATE SET
53
+ ac_count = excluded.ac_count,
54
+ total_count = excluded.total_count,
55
+ total_ac_time = excluded.total_ac_time
56
+ """,
57
+ (problem_id, new_ac_count, new_total_count, new_total_ac_time),
58
+ )
59
+ self.conn.commit()
60
+
61
+ def close(self):
62
+ self.conn.close()
src/main.py ADDED
@@ -0,0 +1,91 @@
1
+ import argparse
2
+ import sys
3
+ import traceback
4
+
5
+ from src.cli.manager import PackageManager
6
+ from src.cli.runner import GameRunner
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(description="Muninn - The Extensible Reciting CLI")
11
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
12
+
13
+ # Command: new
14
+ parser_new = subparsers.add_parser("new", help="Create a new plugin template")
15
+ parser_new.add_argument("pack_id", type=str, help="The ID/name of the new pack")
16
+ parser_new.add_argument(
17
+ "--dir", type=str, default=".", help="Directory to create the template in"
18
+ )
19
+
20
+ # Command: install
21
+ parser_install = subparsers.add_parser(
22
+ "install", help="Install a pack from a directory or zip file"
23
+ )
24
+ parser_install.add_argument(
25
+ "path", type=str, help="Path to the pack directory or .zip file"
26
+ )
27
+
28
+ # Command: uninstall
29
+ parser_uninstall = subparsers.add_parser(
30
+ "uninstall", help="Uninstall a previously installed pack"
31
+ )
32
+ parser_uninstall.add_argument(
33
+ "pack_id", type=str, help="The ID of the pack to uninstall"
34
+ )
35
+
36
+ # Command: list
37
+ subparsers.add_parser("list", help="List all installed packs")
38
+
39
+ # Command: run
40
+ parser_run = subparsers.add_parser("run", help="Run a reciting pack")
41
+ parser_run.add_argument("pack_id", type=str, help="The ID of the pack to run")
42
+
43
+ args = parser.parse_args()
44
+
45
+ if not args.command:
46
+ parser.print_help()
47
+ sys.exit(1)
48
+
49
+ manager = PackageManager()
50
+
51
+ if args.command == "new":
52
+ try:
53
+ manager.create_template(args.pack_id, args.dir)
54
+ except Exception as e: # noqa: BLE001
55
+ print(f"❌ Failed to create template: {e}")
56
+
57
+ elif args.command == "install":
58
+ try:
59
+ manager.install_pack(args.path)
60
+ except Exception as e: # noqa: BLE001
61
+ print(f"❌ Failed to install pack: {e}")
62
+
63
+ elif args.command == "uninstall":
64
+ try:
65
+ manager.uninstall_pack(args.pack_id)
66
+ except Exception as e: # noqa: BLE001
67
+ print(f"❌ Failed to uninstall pack: {e}")
68
+
69
+ elif args.command == "list":
70
+ packs = manager.list_packs()
71
+ if not packs:
72
+ print("No packs installed yet. Use 'muninn install <path>' to add one.")
73
+ else:
74
+ print(f"=== Installed Packs ({len(packs)}) ===")
75
+ for p in packs:
76
+ print(
77
+ f"- {p.get('id')} (v{p.get('version')}): {p.get('name')} by {p.get('author')}"
78
+ )
79
+
80
+ elif args.command == "run":
81
+ try:
82
+ plugin = manager.load_plugin(args.pack_id)
83
+ runner = GameRunner(args.pack_id, plugin)
84
+ runner.run()
85
+ except Exception as e: # noqa: BLE001
86
+ print(f"❌ Failed to run pack '{args.pack_id}': {e}")
87
+ traceback.print_exc()
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
src/ui.py ADDED
@@ -0,0 +1,47 @@
1
+ import os
2
+
3
+
4
+ class TerminalUI:
5
+ @staticmethod
6
+ def clear_screen():
7
+ os.system("cls" if os.name == "nt" else "clear")
8
+
9
+ @staticmethod
10
+ def wait_any_key():
11
+ input("按回车键继续...")
12
+
13
+ @staticmethod
14
+ def print_cyan(text: str):
15
+ print(f"\033[36;1m{text}\033[0m")
16
+
17
+ @staticmethod
18
+ def print_green(text: str):
19
+ print(f"\033[32m{text}\033[0m")
20
+
21
+ @staticmethod
22
+ def print_yellow(text: str):
23
+ print(f"\033[33;1m{text}\033[0m")
24
+
25
+ @staticmethod
26
+ def print_success_banner(time_spent: float):
27
+ print(f"\n\033[42;37;1m Accepted \033[0m 耗时: {time_spent:.2f} 秒")
28
+
29
+ @staticmethod
30
+ def print_error_banner():
31
+ print("\n\033[41;37;1m Wrong Answer \033[0m")
32
+
33
+ @staticmethod
34
+ def print_stats_banner(
35
+ distinct_ac: int,
36
+ total_problems: int,
37
+ ac_count: int,
38
+ total_count: int,
39
+ combo: int,
40
+ avg_time: float,
41
+ ):
42
+ print(
43
+ f"掌握题数: {distinct_ac} / {total_problems}\t"
44
+ f"总正确率: {ac_count} / {total_count}\t"
45
+ f"Combo: {combo}\t"
46
+ f"平均用时: {avg_time:.2f}s\n"
47
+ )