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.
Files changed (24) hide show
  1. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/PKG-INFO +7 -2
  2. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/README.md +6 -1
  3. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/__init__.py +2 -1
  4. moodlesolver-2.1.1/moodlesolver/aipreview.py +26 -0
  5. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/ctx.py +30 -2
  6. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/jsonapi.py +17 -4
  7. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/quizapi.py +7 -6
  8. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/vision.py +15 -4
  9. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/PKG-INFO +7 -2
  10. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/SOURCES.txt +1 -0
  11. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/pyproject.toml +1 -1
  12. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/LICENSE +0 -0
  13. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/airunner.py +0 -0
  14. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/cdn.py +0 -0
  15. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/client.py +0 -0
  16. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/errors.py +0 -0
  17. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/mcp_server.py +0 -0
  18. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/parse.py +0 -0
  19. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver/schema/quiz_protocol.json +0 -0
  20. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/dependency_links.txt +0 -0
  21. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/entry_points.txt +0 -0
  22. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/requires.txt +0 -0
  23. {moodlesolver-2.0.0 → moodlesolver-2.1.1}/moodlesolver.egg-info/top_level.txt +0 -0
  24. {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.0.0
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'res_{r['cmid']}.bin'
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(f'error:{r['cmid']}:{str(e)[:40]}')
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
- def __init__(self, quiz_api, on_ocr=None, image_mode='b64', image_base_url=None):
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
- 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')
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("var b = [...document.querySelectorAll('form button[type=submit]')].find(x => x.textContent.includes('Отправить всё')); if (b) { b.click(); return 'sub'; } return 'nobtn'")
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("var b = [...document.querySelectorAll('form button[type=submit]')].find(x => x.textContent.includes('Отправить всё и завершить тест')); if (b) { b.click(); return 'done'; } return 'noconfirm'")
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
- for root in ('/tmp',):
47
- c = os.path.join(root, path)
48
- if os.path.exists(c):
49
- return c
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.0.0
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
 
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  moodlesolver/__init__.py
5
+ moodlesolver/aipreview.py
5
6
  moodlesolver/airunner.py
6
7
  moodlesolver/cdn.py
7
8
  moodlesolver/client.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "moodlesolver"
7
- version = "2.0.0"
7
+ version = "2.1.1"
8
8
  description = "Moodle quiz automation library and MCP server with local OCR"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes