moodlesolver 2.0.0__tar.gz → 2.1.1__tar.gz
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.
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/PKG-INFO +7 -2
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/README.md +6 -1
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/__init__.py +2 -1
- moodlesolver-2.1.1/moodlesolver/aipreview.py +26 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/ctx.py +30 -2
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/jsonapi.py +17 -4
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/quizapi.py +7 -6
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/vision.py +15 -4
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/PKG-INFO +7 -2
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/SOURCES.txt +1 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/pyproject.toml +1 -1
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/LICENSE +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/airunner.py +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/cdn.py +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/client.py +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/errors.py +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/mcp_server.py +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/parse.py +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/schema/quiz_protocol.json +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/dependency_links.txt +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/entry_points.txt +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/requires.txt +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/top_level.txt +0 -0
- {moodlesolver-2.0.0 → moodlesolver-2.1.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moodlesolver
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: Moodle quiz automation library and MCP server with local OCR
|
|
5
5
|
Author: i-execute
|
|
6
6
|
License: MIT
|
|
@@ -80,7 +80,12 @@ asyncio.run(main())
|
|
|
80
80
|
Каждый ответ содержит: `page`, `question`, `question_hash`, `qtype`, `options`, `checked` (что реально отмечено на сервере), `selects`, `text_inputs`, `timeleft`, `buttons`, а также:
|
|
81
81
|
|
|
82
82
|
- `question_image_b64` / `option_images_b64` — картинки в base64 (или ссылки при `image_mode="url"`)
|
|
83
|
-
- `ocr` — локальное превью каждой картинки: `{"formula": "…", "ru": "…", "en": "…"}`
|
|
83
|
+
- `ocr` — локальное превью каждой картинки: `{"formula": "…", "ru": "…", "en": "…"}`, а при `ai_preview=True` ещё `ai_preview` — описание картинок vision-моделью OpenCode (mimo-v2.6-flash-free по умолчанию)
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
api = JsonAPI(q, ai_preview=True) # включить AI-описание картинок
|
|
87
|
+
api = JsonAPI(q, ai_preview=True, ai_model="opencode/longcat-2.5-preview-free")
|
|
88
|
+
```
|
|
84
89
|
|
|
85
90
|
Подробная схема: `moodlesolver/schema/quiz_protocol.json`.
|
|
86
91
|
|
|
@@ -63,7 +63,12 @@ asyncio.run(main())
|
|
|
63
63
|
Каждый ответ содержит: `page`, `question`, `question_hash`, `qtype`, `options`, `checked` (что реально отмечено на сервере), `selects`, `text_inputs`, `timeleft`, `buttons`, а также:
|
|
64
64
|
|
|
65
65
|
- `question_image_b64` / `option_images_b64` — картинки в base64 (или ссылки при `image_mode="url"`)
|
|
66
|
-
- `ocr` — локальное превью каждой картинки: `{"formula": "…", "ru": "…", "en": "…"}`
|
|
66
|
+
- `ocr` — локальное превью каждой картинки: `{"formula": "…", "ru": "…", "en": "…"}`, а при `ai_preview=True` ещё `ai_preview` — описание картинок vision-моделью OpenCode (mimo-v2.6-flash-free по умолчанию)
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
api = JsonAPI(q, ai_preview=True) # включить AI-описание картинок
|
|
70
|
+
api = JsonAPI(q, ai_preview=True, ai_model="opencode/longcat-2.5-preview-free")
|
|
71
|
+
```
|
|
67
72
|
|
|
68
73
|
Подробная схема: `moodlesolver/schema/quiz_protocol.json`.
|
|
69
74
|
|
|
@@ -5,6 +5,7 @@ from .ctx import CtxAPI
|
|
|
5
5
|
from .airunner import AIRunner
|
|
6
6
|
from .jsonapi import JsonAPI
|
|
7
7
|
from .vision import ocr_file, make_on_ocr
|
|
8
|
+
from .aipreview import ai_preview, opencode_available
|
|
8
9
|
from .cdn import serve_dumps
|
|
9
10
|
from .mcp_server import main as mcp_main
|
|
10
|
-
__all__ = ['AsyncMoodle', 'AsyncTab', 'CamofoxError', 'parse_page', 'QuizAPI', 'CtxAPI', 'AIRunner', 'JsonAPI', 'mcp_main']
|
|
11
|
+
__all__ = ['AsyncMoodle', 'AsyncTab', 'CamofoxError', 'parse_page', 'QuizAPI', 'CtxAPI', 'AIRunner', 'JsonAPI', 'mcp_main', 'ai_preview', 'opencode_available', 'serve_dumps', 'ocr_file', 'make_on_ocr']
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
OC_DEFAULT_MODEL = "opencode/mimo-v2.6-flash-free"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def opencode_available():
|
|
8
|
+
return shutil.which("opencode") is not None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ai_preview(paths, prompt="Опиши что на картинке, весь текст перепиши",
|
|
12
|
+
model=OC_DEFAULT_MODEL, timeout=120):
|
|
13
|
+
if not opencode_available() or not paths:
|
|
14
|
+
return None
|
|
15
|
+
cmd = ["opencode", "run", prompt, "--model", model]
|
|
16
|
+
for p in paths:
|
|
17
|
+
cmd += ["-f", p]
|
|
18
|
+
try:
|
|
19
|
+
out = subprocess.run(cmd, capture_output=True, text=True,
|
|
20
|
+
timeout=timeout).stdout.strip()
|
|
21
|
+
except Exception:
|
|
22
|
+
return None
|
|
23
|
+
if not out or "Error" in out.splitlines()[0]:
|
|
24
|
+
return None
|
|
25
|
+
lines = [ln for ln in out.splitlines() if not ln.startswith(">")]
|
|
26
|
+
return "\n".join(lines).strip() or None
|
|
@@ -95,13 +95,13 @@ class CtxAPI:
|
|
|
95
95
|
d['files'] = []
|
|
96
96
|
if with_resources:
|
|
97
97
|
for r in d['resources']:
|
|
98
|
-
p = f
|
|
98
|
+
p = f"res_{r['cmid']}.bin"
|
|
99
99
|
try:
|
|
100
100
|
got = await self.download_resource(r['cmid'], p)
|
|
101
101
|
if got.get('file'):
|
|
102
102
|
d['files'].append(got['file'])
|
|
103
103
|
except Exception as e:
|
|
104
|
-
d['files'].append(
|
|
104
|
+
d['files'].append("error:%s:%s" % (r['cmid'], str(e)[:40]))
|
|
105
105
|
p = path or f'course_{course_id}.json'
|
|
106
106
|
with open(p, 'w') as f:
|
|
107
107
|
json.dump(d, f, ensure_ascii=False, indent=1)
|
|
@@ -124,3 +124,31 @@ class CtxAPI:
|
|
|
124
124
|
|
|
125
125
|
async def search_courses(self, query):
|
|
126
126
|
return [c for c in await self.get_courses() if query.lower() in c['name'].lower()]
|
|
127
|
+
|
|
128
|
+
async def get_guide(self, quiz_cmid, path=None):
|
|
129
|
+
return await self._get_guide(quiz_cmid, path)
|
|
130
|
+
|
|
131
|
+
async def _get_guide(self, quiz_cmid, path=None):
|
|
132
|
+
for course in await self.get_courses():
|
|
133
|
+
secs = await self.get_course_sections(course['id'])
|
|
134
|
+
for s in secs:
|
|
135
|
+
names = [i for i in s.get('items', [])]
|
|
136
|
+
quiz = next((i for i in names if i.get('cmid') == str(quiz_cmid)), None)
|
|
137
|
+
if not quiz:
|
|
138
|
+
continue
|
|
139
|
+
import re as _re
|
|
140
|
+
m = _re.search(r'(\d+)', quiz.get('name') or '')
|
|
141
|
+
num = m.group(1) if m else ''
|
|
142
|
+
guide = next((i for i in names
|
|
143
|
+
if 'етодичка' in (i.get('name') or '')
|
|
144
|
+
and num and num in (i.get('name') or '')), None)
|
|
145
|
+
if guide:
|
|
146
|
+
gm = _re.search(r'id=(\d+)', guide.get('href') or '')
|
|
147
|
+
if gm:
|
|
148
|
+
p = path or f'guide_{num}.pdf'
|
|
149
|
+
r = await self.download_resource(int(gm.group(1)), p)
|
|
150
|
+
return {'ok': True, 'quiz': quiz.get('name'),
|
|
151
|
+
'guide': guide.get('name'),
|
|
152
|
+
'cmid': int(gm.group(1)), 'file': p,
|
|
153
|
+
'bytes': r.get('bytes')}
|
|
154
|
+
return {'error': 'guide not found'}
|
|
@@ -28,8 +28,9 @@ def _qtype(st):
|
|
|
28
28
|
return 'unknown'
|
|
29
29
|
|
|
30
30
|
class JsonAPI:
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
def __init__(self, quiz_api, on_ocr=None, image_mode='b64',
|
|
32
|
+
image_base_url=None, ai_preview=False,
|
|
33
|
+
ai_model=None):
|
|
33
34
|
self.q = quiz_api
|
|
34
35
|
if on_ocr is None:
|
|
35
36
|
from .vision import make_on_ocr
|
|
@@ -37,6 +38,9 @@ class JsonAPI:
|
|
|
37
38
|
self.on_ocr = on_ocr
|
|
38
39
|
self.image_mode = image_mode
|
|
39
40
|
self.image_base_url = image_base_url
|
|
41
|
+
self.ai = bool(ai_preview)
|
|
42
|
+
from .aipreview import OC_DEFAULT_MODEL
|
|
43
|
+
self.ai_model = ai_model or OC_DEFAULT_MODEL
|
|
40
44
|
|
|
41
45
|
async def handle(self, command):
|
|
42
46
|
c = json.loads(command) if isinstance(command, str) else command
|
|
@@ -78,8 +82,6 @@ class JsonAPI:
|
|
|
78
82
|
return v
|
|
79
83
|
|
|
80
84
|
async def _ocr_preview(self, st):
|
|
81
|
-
if not self.on_ocr:
|
|
82
|
-
return None
|
|
83
85
|
import asyncio as aio
|
|
84
86
|
files = []
|
|
85
87
|
if st.get('question_image_file'):
|
|
@@ -88,6 +90,7 @@ class JsonAPI:
|
|
|
88
90
|
files.append((f, f))
|
|
89
91
|
if not files:
|
|
90
92
|
return None
|
|
93
|
+
from .vision import _resolve
|
|
91
94
|
|
|
92
95
|
async def one(name, f):
|
|
93
96
|
ru, en, formula = await aio.gather(aio.to_thread(self.on_ocr, f, 'текст русский'), aio.to_thread(self.on_ocr, f, 'text english'), aio.to_thread(self.on_ocr, f, 'формула'), return_exceptions=True)
|
|
@@ -102,6 +105,16 @@ class JsonAPI:
|
|
|
102
105
|
out.setdefault('errors', []).append(str(p)[:60])
|
|
103
106
|
else:
|
|
104
107
|
out[p[0]] = p[1]
|
|
108
|
+
if self.ai:
|
|
109
|
+
try:
|
|
110
|
+
from .aipreview import ai_preview
|
|
111
|
+
paths = [_resolve(f) for _, f in files]
|
|
112
|
+
text = await aio.to_thread(ai_preview, paths,
|
|
113
|
+
model=self.ai_model)
|
|
114
|
+
if text:
|
|
115
|
+
out['ai_preview'] = text[:2000]
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
105
118
|
return out or None
|
|
106
119
|
|
|
107
120
|
async def _select(self, c):
|
|
@@ -65,10 +65,11 @@ class QuizAPI:
|
|
|
65
65
|
self.m.dump('q_image.b64', arr[0])
|
|
66
66
|
st['question_image_file'] = 'q_image.b64'
|
|
67
67
|
st['question_image_b64'] = arr[0]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
arr2 = await t.opts_b64()
|
|
69
|
+
for i, b in enumerate(arr2):
|
|
70
|
+
self.m.dump(f'opt_image_{i}.b64', b)
|
|
71
|
+
st.setdefault('option_image_files', []).append(f'opt_image_{i}.b64')
|
|
72
|
+
if arr2:
|
|
72
73
|
st['option_images_b64'] = arr2
|
|
73
74
|
return st
|
|
74
75
|
|
|
@@ -115,9 +116,9 @@ class QuizAPI:
|
|
|
115
116
|
break
|
|
116
117
|
if not t:
|
|
117
118
|
return {'error': 'no summary'}
|
|
118
|
-
r = await t.evaluate("
|
|
119
|
+
r = await t.evaluate("(() => { const b = [...document.querySelectorAll('form button[type=submit]')].find(x => x.textContent.includes('Отправить всё')); if (b) { b.click(); return 'sub'; } return 'nobtn'; })()")
|
|
119
120
|
await asyncio.sleep(4)
|
|
120
|
-
r2 = await t.evaluate("
|
|
121
|
+
r2 = await t.evaluate("(() => { const b = [...document.querySelectorAll('form button[type=submit]')].find(x => x.textContent.includes('Отправить всё и завершить тест')); if (b) { b.click(); return 'done'; } return 'noconfirm'; })()")
|
|
121
122
|
return r2.get('result')
|
|
122
123
|
|
|
123
124
|
async def get_result(self, quiz_id):
|
|
@@ -43,10 +43,21 @@ def _resolve(path, dumps_dir=None):
|
|
|
43
43
|
if not os.path.exists(p) and dumps_dir:
|
|
44
44
|
p = os.path.join(dumps_dir, path)
|
|
45
45
|
if not os.path.exists(p):
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
c = os.path.join('/tmp', path)
|
|
47
|
+
if os.path.exists(c):
|
|
48
|
+
p = c
|
|
49
|
+
if os.path.exists(p) and p.endswith('.b64'):
|
|
50
|
+
import base64
|
|
51
|
+
try:
|
|
52
|
+
raw = open(p, 'rb').read()
|
|
53
|
+
data = base64.b64decode(raw)
|
|
54
|
+
if data[:4] == b'\x89PNG' or data[:2] == b'\xff\xd8':
|
|
55
|
+
png = '/tmp/ocr_' + os.path.basename(p)[:-4] + '.png'
|
|
56
|
+
with open(png, 'wb') as f:
|
|
57
|
+
f.write(data)
|
|
58
|
+
return png
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
50
61
|
return p
|
|
51
62
|
|
|
52
63
|
def ocr_file(path, prompt=None, dumps_dir=None):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: moodlesolver
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: Moodle quiz automation library and MCP server with local OCR
|
|
5
5
|
Author: i-execute
|
|
6
6
|
License: MIT
|
|
@@ -80,7 +80,12 @@ asyncio.run(main())
|
|
|
80
80
|
Каждый ответ содержит: `page`, `question`, `question_hash`, `qtype`, `options`, `checked` (что реально отмечено на сервере), `selects`, `text_inputs`, `timeleft`, `buttons`, а также:
|
|
81
81
|
|
|
82
82
|
- `question_image_b64` / `option_images_b64` — картинки в base64 (или ссылки при `image_mode="url"`)
|
|
83
|
-
- `ocr` — локальное превью каждой картинки: `{"formula": "…", "ru": "…", "en": "…"}`
|
|
83
|
+
- `ocr` — локальное превью каждой картинки: `{"formula": "…", "ru": "…", "en": "…"}`, а при `ai_preview=True` ещё `ai_preview` — описание картинок vision-моделью OpenCode (mimo-v2.6-flash-free по умолчанию)
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
api = JsonAPI(q, ai_preview=True) # включить AI-описание картинок
|
|
87
|
+
api = JsonAPI(q, ai_preview=True, ai_model="opencode/longcat-2.5-preview-free")
|
|
88
|
+
```
|
|
84
89
|
|
|
85
90
|
Подробная схема: `moodlesolver/schema/quiz_protocol.json`.
|
|
86
91
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|