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.
- muninn_cli-0.1.1.dist-info/METADATA +196 -0
- muninn_cli-0.1.1.dist-info/RECORD +16 -0
- muninn_cli-0.1.1.dist-info/WHEEL +4 -0
- muninn_cli-0.1.1.dist-info/entry_points.txt +2 -0
- muninn_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +0 -0
- src/cli/__init__.py +0 -0
- src/cli/manager.py +161 -0
- src/cli/runner.py +121 -0
- src/core/__init__.py +0 -0
- src/core/base_plugin.py +32 -0
- src/core/helpers.py +205 -0
- src/core/scheduler.py +44 -0
- src/core/state.py +62 -0
- src/main.py +91 -0
- src/ui.py +47 -0
|
@@ -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,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
|
src/core/base_plugin.py
ADDED
|
@@ -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
|
+
)
|