en2zmax revised this gist . Go to revision
1 file changed, 2250 insertions
moodle.py(file created)
| @@ -0,0 +1,2250 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | # -*- coding: utf-8 -*- | |
| 3 | + | """ | |
| 4 | + | moodle.py — автономное прохождение Moodle-тестов. | |
| 5 | + | ||
| 6 | + | Поддержка: Moodle 3.x / 4.x quiz module. | |
| 7 | + | Generic-fallback: HTML-тесты с radio/checkbox/text-input. | |
| 8 | + | ||
| 9 | + | Структура Moodle quiz: | |
| 10 | + | <div id="qN" class="que multichoice ..."> | |
| 11 | + | <div class="info"><h3 class="no">Question <span>N</span></h3></div> | |
| 12 | + | <div class="formulation"> | |
| 13 | + | <div class="qtext">…текст вопроса…</div> | |
| 14 | + | <div class="ablock"> | |
| 15 | + | <div class="answer"> | |
| 16 | + | <div class="r0"> | |
| 17 | + | <input type="radio" id="qN:1_answer0" …> | |
| 18 | + | <label for="qN:1_answer0">Вариант А</label> | |
| 19 | + | </div> | |
| 20 | + | … | |
| 21 | + | </div> | |
| 22 | + | </div> | |
| 23 | + | </div> | |
| 24 | + | </div> | |
| 25 | + | ||
| 26 | + | Навигация: | |
| 27 | + | <input type="submit" name="next" value="Следующая страница"> | |
| 28 | + | <input type="submit" name="finishattempt" value="Завершить попытку..."> | |
| 29 | + | <input type="submit" name="submit" value="Отправить всё и завершить"> | |
| 30 | + | """ | |
| 31 | + | ||
| 32 | + | import asyncio | |
| 33 | + | import logging | |
| 34 | + | import random | |
| 35 | + | import re | |
| 36 | + | from typing import Any, Callable, Dict, List, Optional | |
| 37 | + | ||
| 38 | + | logger = logging.getLogger(__name__) | |
| 39 | + | ||
| 40 | + | MAX_QUESTIONS = 200 # safety cap | |
| 41 | + | ||
| 42 | + | # --------------------------------------------------------------------------- | |
| 43 | + | # Moodle quiz page signatures | |
| 44 | + | # --------------------------------------------------------------------------- | |
| 45 | + | # Ordered list — first match wins. | |
| 46 | + | # detect conditions use AND-logic: | |
| 47 | + | # html_cls — all listed substrings must appear in page text | |
| 48 | + | # el_type — at least one interactive element must have this type | |
| 49 | + | # | |
| 50 | + | # interaction values: | |
| 51 | + | # "click_radio_input" — click input[type=radio] by #id selector | |
| 52 | + | # "click_checkbox_input" — click input[type=checkbox] by #id selector | |
| 53 | + | # "type_text" — type text into input/textarea | |
| 54 | + | # "click_by_text" — click element by visible text (generic fallback) | |
| 55 | + | # --------------------------------------------------------------------------- | |
| 56 | + | MOODLE_SIGNATURES: List[Dict] = [ | |
| 57 | + | # ── Moodle: True/False ──────────────────────────────────────────────── | |
| 58 | + | { | |
| 59 | + | "name": "moodle_truefalse", | |
| 60 | + | "detect": {"html_cls": ["que", "truefalse"], "el_type": ["radio"]}, | |
| 61 | + | "interaction": "click_radio_input", | |
| 62 | + | "question_class": "qtext", | |
| 63 | + | "multiple": False, | |
| 64 | + | }, | |
| 65 | + | # ── Moodle: Matching (select dropdowns) ─────────────────────────────── | |
| 66 | + | { | |
| 67 | + | "name": "moodle_matching", | |
| 68 | + | "detect": {"html_cls": ["que", "match"]}, | |
| 69 | + | "interaction": "click_by_text", | |
| 70 | + | "question_class": "qtext", | |
| 71 | + | "multiple": False, | |
| 72 | + | }, | |
| 73 | + | # ── Moodle: Multiple-select (checkboxes) ────────────────────────────── | |
| 74 | + | { | |
| 75 | + | "name": "moodle_multianswer", | |
| 76 | + | "detect": {"html_cls": ["que"], "el_type": ["checkbox"]}, | |
| 77 | + | "interaction": "click_checkbox_input", | |
| 78 | + | "question_class": "qtext", | |
| 79 | + | "multiple": True, | |
| 80 | + | }, | |
| 81 | + | # ── Moodle: Single-choice (radio) ───────────────────────────────────── | |
| 82 | + | { | |
| 83 | + | "name": "moodle_multichoice", | |
| 84 | + | "detect": {"html_cls": ["que"], "el_type": ["radio"]}, | |
| 85 | + | "interaction": "click_radio_input", | |
| 86 | + | "question_class": "qtext", | |
| 87 | + | "multiple": False, | |
| 88 | + | }, | |
| 89 | + | # ── Moodle: Short-answer / Essay / Numerical (text input) ───────────── | |
| 90 | + | { | |
| 91 | + | "name": "moodle_shortanswer", | |
| 92 | + | "detect": {"html_cls": ["que"]}, | |
| 93 | + | "interaction": "type_text", | |
| 94 | + | "question_class": "qtext", | |
| 95 | + | "multiple": False, | |
| 96 | + | }, | |
| 97 | + | # ── Generic fallback: radio buttons ─────────────────────────────────── | |
| 98 | + | { | |
| 99 | + | "name": "generic_radio", | |
| 100 | + | "detect": {"el_type": ["radio"]}, | |
| 101 | + | "interaction": "click_radio_input", | |
| 102 | + | "question_class": None, | |
| 103 | + | "multiple": False, | |
| 104 | + | }, | |
| 105 | + | # ── Generic fallback: checkboxes ────────────────────────────────────── | |
| 106 | + | { | |
| 107 | + | "name": "generic_checkbox", | |
| 108 | + | "detect": {"el_type": ["checkbox"]}, | |
| 109 | + | "interaction": "click_checkbox_input", | |
| 110 | + | "question_class": None, | |
| 111 | + | "multiple": True, | |
| 112 | + | }, | |
| 113 | + | # ── Generic fallback: clickable div/span options ────────────────────── | |
| 114 | + | { | |
| 115 | + | "name": "generic_div_options", | |
| 116 | + | "detect": {"el_type": ["clickable"]}, | |
| 117 | + | "interaction": "click_by_text", | |
| 118 | + | "question_class": None, | |
| 119 | + | "multiple": False, | |
| 120 | + | }, | |
| 121 | + | ] | |
| 122 | + | ||
| 123 | + | # Moodle-specific submit/navigate button selectors (tried in order) | |
| 124 | + | MOODLE_NAV_SELECTORS = [ | |
| 125 | + | "input[name='next']", | |
| 126 | + | "input[name='finishattempt']", | |
| 127 | + | "input[name='submit']", | |
| 128 | + | ".submitbtns input[type='submit']", | |
| 129 | + | "button[type='submit']", | |
| 130 | + | ] | |
| 131 | + | ||
| 132 | + | # Markers that indicate the quiz is complete (Moodle + generic) | |
| 133 | + | RESULT_MARKERS = [ | |
| 134 | + | # Moodle-specific | |
| 135 | + | "quizsummaryofattempt", | |
| 136 | + | "gradingdetails", | |
| 137 | + | "gradingsummary", | |
| 138 | + | "reviewsummary", | |
| 139 | + | "mod-quiz-attempt-summary", | |
| 140 | + | # Generic | |
| 141 | + | "результаты теста", | |
| 142 | + | "ваш результат", | |
| 143 | + | "your score", | |
| 144 | + | "тест завершён", | |
| 145 | + | "тест завершен", | |
| 146 | + | "test complete", | |
| 147 | + | ] | |
| 148 | + | ||
| 149 | + | ||
| 150 | + | # --------------------------------------------------------------------------- | |
| 151 | + | # Signature detection | |
| 152 | + | # --------------------------------------------------------------------------- | |
| 153 | + | ||
| 154 | + | def _detect_signature(page_text: str, elems: list) -> Dict: | |
| 155 | + | """Return the first matching signature from MOODLE_SIGNATURES.""" | |
| 156 | + | text_lower = page_text.lower() | |
| 157 | + | el_types = {(e.get("type") or "").lower() for e in elems} | |
| 158 | + | ||
| 159 | + | for sig in MOODLE_SIGNATURES: | |
| 160 | + | d = sig.get("detect", {}) | |
| 161 | + | # Check html_cls — all substrings must be present in page text | |
| 162 | + | if "html_cls" in d: | |
| 163 | + | if not all(cls in text_lower for cls in d["html_cls"]): | |
| 164 | + | continue | |
| 165 | + | # Check el_type — at least one must match | |
| 166 | + | if "el_type" in d: | |
| 167 | + | if not any(t in el_types for t in d["el_type"]): | |
| 168 | + | continue | |
| 169 | + | logger.info(f"moodle_solver: matched signature '{sig['name']}'") | |
| 170 | + | return sig | |
| 171 | + | ||
| 172 | + | return { | |
| 173 | + | "name": "unknown", | |
| 174 | + | "interaction": "click_by_text", | |
| 175 | + | "question_class": None, | |
| 176 | + | "multiple": False, | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | ||
| 180 | + | # --------------------------------------------------------------------------- | |
| 181 | + | # Question text extraction | |
| 182 | + | # --------------------------------------------------------------------------- | |
| 183 | + | ||
| 184 | + | # Navigation / UI phrases that are never the question text | |
| 185 | + | _NAV_WORDS = frozenset([ | |
| 186 | + | "назад", "далее", "next", "back", "finish", "submit", "отправить", | |
| 187 | + | "завершить", "следующая", "предыдущая", "previous", | |
| 188 | + | ]) | |
| 189 | + | ||
| 190 | + | # Lines that look like Moodle metadata / UI noise — skip when extracting question | |
| 191 | + | _NOISE_RE = re.compile( | |
| 192 | + | r'(?:' | |
| 193 | + | r'перейти к основному содержан|skip to main content|skip navigation' | |
| 194 | + | r'|ещё не отвечен|not yet answered|не отвечен|пока нет ответа' | |
| 195 | + | r'|^балл\s*:|^mark[s]?\s*:|^grade\s*:' | |
| 196 | + | r'|отметить вопрос|flag question' | |
| 197 | + | r'|^навигация по тесту|^quiz navigation' | |
| 198 | + | r'|^информация о вопросе|^question information' | |
| 199 | + | r'|^выберите один|^select one|^choose' | |
| 200 | + | r')', | |
| 201 | + | re.IGNORECASE, | |
| 202 | + | ) | |
| 203 | + | ||
| 204 | + | ||
| 205 | + | def _is_noise_line(s: str) -> bool: | |
| 206 | + | """True if line is Moodle UI noise / skip-nav / metadata.""" | |
| 207 | + | return bool(_NOISE_RE.search(s)) | |
| 208 | + | ||
| 209 | + | ||
| 210 | + | def _extract_question_text( | |
| 211 | + | page_text: str, | |
| 212 | + | option_hints: List[str], | |
| 213 | + | question_class: Optional[str] = None, | |
| 214 | + | ) -> str: | |
| 215 | + | """Extract the question text from plain page content. | |
| 216 | + | ||
| 217 | + | Strategies (in order): | |
| 218 | + | 1. Line just before the first answer option (most reliable for Moodle) | |
| 219 | + | 2. Line after "Question N" / "Вопрос N" heading | |
| 220 | + | 3. Line after progress indicator "N / M" or "N из M" | |
| 221 | + | 4. First line containing "?" | |
| 222 | + | 5. First sufficiently long line | |
| 223 | + | """ | |
| 224 | + | lines = [ln.strip() for ln in page_text.splitlines() if ln.strip()] | |
| 225 | + | ||
| 226 | + | def _is_bad(s: str) -> bool: | |
| 227 | + | if not s.split(): | |
| 228 | + | return True | |
| 229 | + | if s.lower().split()[0] in _NAV_WORDS: | |
| 230 | + | return True | |
| 231 | + | if _is_noise_line(s): | |
| 232 | + | return True | |
| 233 | + | return False | |
| 234 | + | ||
| 235 | + | # Strategy 1: find the line immediately before the first option hint | |
| 236 | + | if option_hints: | |
| 237 | + | best_idx = None | |
| 238 | + | for i, line in enumerate(lines): | |
| 239 | + | for hint in option_hints: | |
| 240 | + | hint_key = hint[:25].lower().strip() | |
| 241 | + | if hint_key and len(hint_key) > 3 and hint_key in line.lower(): | |
| 242 | + | if best_idx is None or i < best_idx: | |
| 243 | + | best_idx = i | |
| 244 | + | break | |
| 245 | + | if best_idx and best_idx > 0: | |
| 246 | + | for i in range(best_idx - 1, max(-1, best_idx - 6), -1): | |
| 247 | + | candidate = lines[i] | |
| 248 | + | if len(candidate) > 10 and not _is_bad(candidate): | |
| 249 | + | if not re.match(r'^\d+\s*(?:[/из])\s*\d+$', candidate): | |
| 250 | + | return candidate[:120] | |
| 251 | + | ||
| 252 | + | # Strategy 2: line after "Question N" / "Вопрос N" heading | |
| 253 | + | for i, line in enumerate(lines): | |
| 254 | + | if re.match(r'(?:question|вопрос|задание)\s+\d+', line, re.IGNORECASE): | |
| 255 | + | for j in range(i + 1, min(i + 8, len(lines))): | |
| 256 | + | candidate = lines[j] | |
| 257 | + | if len(candidate) > 10 and not _is_bad(candidate): | |
| 258 | + | return candidate[:120] | |
| 259 | + | ||
| 260 | + | # Strategy 3: line after progress "N / M" or "N из M" | |
| 261 | + | for i, line in enumerate(lines): | |
| 262 | + | if re.match(r'^\s*\d+\s*(?:/|из|of)\s*\d+\s*$', line) and i + 1 < len(lines): | |
| 263 | + | candidate = lines[i + 1] | |
| 264 | + | if len(candidate) > 10 and not _is_bad(candidate): | |
| 265 | + | return candidate[:120] | |
| 266 | + | ||
| 267 | + | # Strategy 4: first line with "?" of sufficient length | |
| 268 | + | for line in lines: | |
| 269 | + | if "?" in line and len(line) >= 15 and not _is_bad(line): | |
| 270 | + | return line[:120] | |
| 271 | + | ||
| 272 | + | # Strategy 5: first sufficiently long non-noise line | |
| 273 | + | for line in lines: | |
| 274 | + | if len(line) > 15 and not _is_bad(line): | |
| 275 | + | return line[:120] | |
| 276 | + | ||
| 277 | + | return "" | |
| 278 | + | ||
| 279 | + | ||
| 280 | + | # --------------------------------------------------------------------------- | |
| 281 | + | # Main solver | |
| 282 | + | # --------------------------------------------------------------------------- | |
| 283 | + | ||
| 284 | + | async def solve_moodle_test( | |
| 285 | + | send_fn: Callable, | |
| 286 | + | tab_id: int, | |
| 287 | + | student_name: str = "", | |
| 288 | + | timing_hint: str = "", | |
| 289 | + | mode: str = "", | |
| 290 | + | finish_test: bool = False, | |
| 291 | + | db: Optional[Any] = None, | |
| 292 | + | user_id: Optional[Any] = None, | |
| 293 | + | progress_queue: Optional[Any] = None, | |
| 294 | + | trace_id: Optional[Any] = None, | |
| 295 | + | parent_span_id: Optional[Any] = None, | |
| 296 | + | ) -> str: | |
| 297 | + | """Autonomously solve a Moodle (or generic HTML) web quiz. | |
| 298 | + | ||
| 299 | + | mode — operating mode: | |
| 300 | + | "" (default) — normal: answer questions automatically | |
| 301 | + | "trace" — tracing/learning mode: collect HTML of each question without answering. | |
| 302 | + | Use when asked to run in "режим трассировки" or "режим обучения". | |
| 303 | + | ||
| 304 | + | finish_test — if True, after answering all questions submit the test | |
| 305 | + | (click "Отправить всё и завершить тест") after verifying all answers are saved. | |
| 306 | + | Use when user explicitly asks to finish/submit the test after solving. | |
| 307 | + | ||
| 308 | + | timing_hint — optional user timing instruction: | |
| 309 | + | Examples: "5 секунд на вопрос", "3 минуты на весь тест", | |
| 310 | + | "от 10 до 20 секунд", "быстро", "медленно". | |
| 311 | + | Leave empty for automatic adaptive timing based on question complexity. | |
| 312 | + | """ | |
| 313 | + | from uuid import UUID as _UUID | |
| 314 | + | from core.tracing.tracing_service import get_tracing_service, OperationType | |
| 315 | + | ||
| 316 | + | # ── Tracing helper ──────────────────────────────────────────────────── | |
| 317 | + | _tracing = get_tracing_service() | |
| 318 | + | ||
| 319 | + | async def _start_span(op_type, metadata=None): | |
| 320 | + | if not _tracing: | |
| 321 | + | return None | |
| 322 | + | return await _tracing.start_span( | |
| 323 | + | operation_type=op_type, | |
| 324 | + | trace_id=trace_id, | |
| 325 | + | parent_span_id=parent_span_id, | |
| 326 | + | metadata=metadata or {}, | |
| 327 | + | ) | |
| 328 | + | ||
| 329 | + | async def _end_span(span_id, success=True, error_message=None, metadata_updates=None): | |
| 330 | + | if span_id and _tracing: | |
| 331 | + | await _tracing.end_span(span_id, success=success, | |
| 332 | + | error_message=error_message, | |
| 333 | + | metadata_updates=metadata_updates) | |
| 334 | + | ||
| 335 | + | # Start MOODLE_SOLVE span | |
| 336 | + | _solve_span = await _start_span(OperationType.MOODLE_SOLVE, { | |
| 337 | + | "mode": mode or "normal", | |
| 338 | + | "tab_id": tab_id, | |
| 339 | + | "finish_test": finish_test, | |
| 340 | + | }) | |
| 341 | + | ||
| 342 | + | _qa_records: List[dict] = [] | |
| 343 | + | _results_text: str = "" | |
| 344 | + | _moodle_results_data: Optional[dict] = None | |
| 345 | + | answered = 0 | |
| 346 | + | _total_simulated_sec = 0.0 | |
| 347 | + | _test_submitted: bool = False | |
| 348 | + | _submit_problem: str = "" | |
| 349 | + | ||
| 350 | + | # ── Parse timing_hint ───────────────────────────────────────────────── | |
| 351 | + | _override_fixed: Optional[float] = None | |
| 352 | + | _override_total: Optional[float] = None | |
| 353 | + | _override_min: Optional[float] = None | |
| 354 | + | _override_max: Optional[float] = None | |
| 355 | + | _override_scale: float = 1.0 | |
| 356 | + | ||
| 357 | + | if timing_hint: | |
| 358 | + | _th = timing_hint.lower().strip() | |
| 359 | + | _rm = re.search( | |
| 360 | + | r'(?:от\s*)?(\d+(?:[.,]\d+)?)\s*(?:до|to|-)\s*(\d+(?:[.,]\d+)?)', _th | |
| 361 | + | ) | |
| 362 | + | if _rm: | |
| 363 | + | _override_min = float(_rm.group(1).replace(',', '.')) | |
| 364 | + | _override_max = float(_rm.group(2).replace(',', '.')) | |
| 365 | + | elif re.search(r'(\d+(?:[.,]\d+)?)\s*(?:мин|min)', _th) and \ | |
| 366 | + | re.search(r'тест|test|весь|all|total', _th): | |
| 367 | + | _m = re.search(r'(\d+(?:[.,]\d+)?)\s*(?:мин|min)', _th) | |
| 368 | + | _override_total = float(_m.group(1).replace(',', '.')) * 60 | |
| 369 | + | elif re.search(r'(\d+(?:[.,]\d+)?)\s*(?:секунд|сек|с\b|sec|s\b)', _th): | |
| 370 | + | _m = re.search(r'(\d+(?:[.,]\d+)?)\s*(?:секунд|сек|с\b|sec|s\b)', _th) | |
| 371 | + | _override_fixed = float(_m.group(1).replace(',', '.')) | |
| 372 | + | elif re.search(r'очень быстр|very fast|максимально быстр', _th): | |
| 373 | + | _override_scale = 0.2 | |
| 374 | + | elif re.search(r'быстр|fast|quick|speed', _th): | |
| 375 | + | _override_scale = 0.4 | |
| 376 | + | elif re.search(r'медленн|slow', _th): | |
| 377 | + | _override_scale = 2.0 | |
| 378 | + | ||
| 379 | + | def _human_delay_sec(q_text: str, options: Optional[List[str]] = None) -> float: | |
| 380 | + | if _override_fixed is not None: | |
| 381 | + | return random.uniform(max(0.5, _override_fixed * 0.85), _override_fixed * 1.15) | |
| 382 | + | if _override_min is not None and _override_max is not None: | |
| 383 | + | return random.uniform(_override_min, _override_max) | |
| 384 | + | total_chars = len(q_text or "") + sum(len(o) for o in (options or [])) | |
| 385 | + | if total_chars < 60: | |
| 386 | + | base = random.uniform(2.0, 4.5) | |
| 387 | + | elif total_chars < 200: | |
| 388 | + | base = random.uniform(4.0, 7.5) | |
| 389 | + | elif total_chars < 400: | |
| 390 | + | base = random.uniform(7.0, 12.0) | |
| 391 | + | else: | |
| 392 | + | base = random.uniform(10.0, 18.0) | |
| 393 | + | if re.search(r'\d+\s*[\+\-\×\÷\*/]\s*\d+|\b\d{3,}\b', q_text or ""): | |
| 394 | + | base += random.uniform(1.5, 4.0) | |
| 395 | + | return base * _override_scale | |
| 396 | + | ||
| 397 | + | # ── Auto-resolve tab_id ─────────────────────────────────────────────── | |
| 398 | + | try: | |
| 399 | + | await send_fn("browser.getTabContent", {"tabId": tab_id, "offset": 0}) | |
| 400 | + | except Exception: | |
| 401 | + | logger.warning(f"moodle_solver: tab_id={tab_id} invalid, auto-detecting active tab") | |
| 402 | + | try: | |
| 403 | + | tabs_result = await send_fn("browser.listTabs", {"offset": 0, "limit": 50}) | |
| 404 | + | tabs = tabs_result if isinstance(tabs_result, list) else \ | |
| 405 | + | (tabs_result or {}).get("tabs", []) | |
| 406 | + | if tabs: | |
| 407 | + | active = next((t for t in tabs if t.get("active")), None) | |
| 408 | + | tab_id = (active or tabs[-1]).get("id", tab_id) | |
| 409 | + | logger.info(f"moodle_solver: auto-resolved tab_id={tab_id}") | |
| 410 | + | else: | |
| 411 | + | return "Error: No open tabs found in browser" | |
| 412 | + | except Exception as tab_err: | |
| 413 | + | return f"Error: Could not detect active tab: {tab_err}" | |
| 414 | + | ||
| 415 | + | def _emit(event: dict): | |
| 416 | + | if progress_queue is not None: | |
| 417 | + | try: | |
| 418 | + | progress_queue.put_nowait(event) | |
| 419 | + | except Exception: | |
| 420 | + | pass | |
| 421 | + | ||
| 422 | + | # ── LLM helper ──────────────────────────────────────────────────────── | |
| 423 | + | async def _get_llm(): | |
| 424 | + | if db is None or user_id is None: | |
| 425 | + | return None | |
| 426 | + | try: | |
| 427 | + | from sqlalchemy import select as sa_select | |
| 428 | + | from core.models.settings import ConnectorConfig | |
| 429 | + | stmt = sa_select(ConnectorConfig).where( | |
| 430 | + | ConnectorConfig.user_id == ( | |
| 431 | + | user_id if isinstance(user_id, _UUID) else _UUID(str(user_id)) | |
| 432 | + | ), | |
| 433 | + | ConnectorConfig.is_enabled == True, | |
| 434 | + | ) | |
| 435 | + | rows = (await db.execute(stmt)).scalars().all() | |
| 436 | + | if not rows: | |
| 437 | + | return None | |
| 438 | + | connector = next((c for c in rows if c.is_default), rows[0]) | |
| 439 | + | models = (connector.config or {}).get("models", []) | |
| 440 | + | model = models[0] if models else "deepseek-chat" | |
| 441 | + | if connector.connector_name == "ollama": | |
| 442 | + | from langchain_ollama import ChatOllama | |
| 443 | + | kw: Dict[str, Any] = {} | |
| 444 | + | if model.lower().startswith("qwen3"): | |
| 445 | + | kw["reasoning"] = False | |
| 446 | + | return ChatOllama( | |
| 447 | + | base_url=connector.endpoint or "", model=model, | |
| 448 | + | temperature=0.3, **kw | |
| 449 | + | ) | |
| 450 | + | from langchain_openai import ChatOpenAI | |
| 451 | + | from pydantic import SecretStr | |
| 452 | + | return ChatOpenAI( | |
| 453 | + | base_url=connector.endpoint or "", model=model, | |
| 454 | + | api_key=SecretStr(connector.api_key) if connector.api_key else None, | |
| 455 | + | temperature=0.3, timeout=30, max_retries=1, | |
| 456 | + | ) | |
| 457 | + | except Exception as exc: | |
| 458 | + | logger.warning(f"moodle_solver: failed to create LLM: {exc}") | |
| 459 | + | return None | |
| 460 | + | ||
| 461 | + | llm = await _get_llm() | |
| 462 | + | ||
| 463 | + | async def _ask_llm(prompt: str) -> str: | |
| 464 | + | if llm is None: | |
| 465 | + | return "" | |
| 466 | + | try: | |
| 467 | + | from langchain_core.messages import HumanMessage as HM | |
| 468 | + | resp = await llm.ainvoke([HM(content=prompt)]) | |
| 469 | + | return (resp.content or "").strip() | |
| 470 | + | except Exception as exc: | |
| 471 | + | logger.warning(f"moodle_solver LLM call failed: {exc}") | |
| 472 | + | return "" | |
| 473 | + | ||
| 474 | + | # ── Page reader ─────────────────────────────────────────────────────── | |
| 475 | + | async def _read_page(): | |
| 476 | + | content, elems = await asyncio.gather( | |
| 477 | + | send_fn("browser.getTabContent", {"tabId": tab_id, "offset": 0}), | |
| 478 | + | send_fn("browser.getInteractiveElements", {"tabId": tab_id, "maxElements": 50}), | |
| 479 | + | ) | |
| 480 | + | text = content.get("content", "") if isinstance(content, dict) else str(content) | |
| 481 | + | elem_list = elems.get("elements", []) if isinstance(elems, dict) else [] | |
| 482 | + | return text, elem_list | |
| 483 | + | ||
| 484 | + | async def _read_selector(selector: str, max_len: int = 500) -> str: | |
| 485 | + | """Extract text content of a DOM element via CSS selector. | |
| 486 | + | ||
| 487 | + | Returns empty string if extension doesn't support selector parameter | |
| 488 | + | (detected by result being suspiciously long = full page text). | |
| 489 | + | """ | |
| 490 | + | try: | |
| 491 | + | result = await send_fn("browser.getTabContent", { | |
| 492 | + | "tabId": tab_id, "offset": 0, "selector": selector, | |
| 493 | + | }) | |
| 494 | + | text = (result.get("content", "") if isinstance(result, dict) else "").strip() | |
| 495 | + | # If very long, extension probably ignores selector → full page text | |
| 496 | + | if len(text) > max_len: | |
| 497 | + | return "" | |
| 498 | + | return text | |
| 499 | + | except Exception: | |
| 500 | + | return "" | |
| 501 | + | ||
| 502 | + | async def _read_qtext() -> str: | |
| 503 | + | """Try to extract Moodle .qtext content directly.""" | |
| 504 | + | return await _read_selector(".qtext", max_len=500) | |
| 505 | + | ||
| 506 | + | async def _read_moodle_answers() -> List[str]: | |
| 507 | + | """Try to extract Moodle answer texts via .answer selector.""" | |
| 508 | + | raw = await _read_selector(".answer", max_len=2000) | |
| 509 | + | if not raw: | |
| 510 | + | return [] | |
| 511 | + | # Each line in .answer innerText is typically one option | |
| 512 | + | return [ln.strip() for ln in raw.splitlines() | |
| 513 | + | if ln.strip() and len(ln.strip()) > 1] | |
| 514 | + | ||
| 515 | + | async def _read_question_html() -> str: | |
| 516 | + | """Read outerHTML of the current Moodle question block (.que). | |
| 517 | + | Falls back to .formulation, then full page HTML (truncated).""" | |
| 518 | + | for sel in [".que", ".formulation", "#responseform"]: | |
| 519 | + | try: | |
| 520 | + | result = await send_fn("browser.getTabContent", { | |
| 521 | + | "tabId": tab_id, "offset": 0, | |
| 522 | + | "selector": sel, "html": True, | |
| 523 | + | }) | |
| 524 | + | html = (result.get("content", "") if isinstance(result, dict) else "").strip() | |
| 525 | + | if html and len(html) > 20: | |
| 526 | + | return html | |
| 527 | + | except Exception: | |
| 528 | + | pass | |
| 529 | + | return "" | |
| 530 | + | ||
| 531 | + | # ── Click helper ────────────────────────────────────────────────────── | |
| 532 | + | async def _click_option( | |
| 533 | + | opt_text: str, | |
| 534 | + | el: Optional[dict], | |
| 535 | + | interaction: str, | |
| 536 | + | idx: int, | |
| 537 | + | ) -> bool: | |
| 538 | + | """Click an answer option. Returns True on success.""" | |
| 539 | + | # Radio / checkbox: prefer #id selector (most reliable for Moodle) | |
| 540 | + | if interaction in ("click_radio_input", "click_checkbox_input"): | |
| 541 | + | el_id = (el or {}).get("id") or "" | |
| 542 | + | el_sel = (el or {}).get("selector") or (f"#{el_id}" if el_id else "") | |
| 543 | + | if el_sel: | |
| 544 | + | try: | |
| 545 | + | await send_fn("browser.clickElement", { | |
| 546 | + | "tabId": tab_id, "selector": el_sel | |
| 547 | + | }) | |
| 548 | + | logger.debug(f"moodle_solver: clicked {interaction} '{el_sel}'") | |
| 549 | + | return True | |
| 550 | + | except Exception: | |
| 551 | + | pass # fall through to text-based click | |
| 552 | + | ||
| 553 | + | # Fallback: click by visible text | |
| 554 | + | if opt_text: | |
| 555 | + | try: | |
| 556 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "text": opt_text}) | |
| 557 | + | logger.debug(f"moodle_solver: clicked by text '{opt_text[:50]}'") | |
| 558 | + | return True | |
| 559 | + | except Exception as e: | |
| 560 | + | logger.warning( | |
| 561 | + | f"moodle_solver: all click strategies failed " | |
| 562 | + | f"for '{opt_text[:50]}': {e}" | |
| 563 | + | ) | |
| 564 | + | return False | |
| 565 | + | ||
| 566 | + | # ── Step 1: detect platform and navigate to attempt page ───────────── | |
| 567 | + | _emit({"status": "test_reading", "question": 0, "total": "?"}) | |
| 568 | + | ||
| 569 | + | # Detect quiz platform via extension command (may not be available in older extension) | |
| 570 | + | _platform_info = {} | |
| 571 | + | _detect_available = False | |
| 572 | + | try: | |
| 573 | + | _platform_info = await send_fn("browser.detectQuizPlatform", {"tabId": tab_id}) or {} | |
| 574 | + | _detect_available = bool(_platform_info) | |
| 575 | + | except Exception as e: | |
| 576 | + | logger.warning(f"moodle_solver: detectQuizPlatform not available: {e}") | |
| 577 | + | ||
| 578 | + | _platform = _platform_info.get("platform", "unknown") | |
| 579 | + | _page_type = _platform_info.get("pageType", "unknown") | |
| 580 | + | ||
| 581 | + | # ── Fallback detection if detectQuizPlatform unavailable ── | |
| 582 | + | if not _detect_available: | |
| 583 | + | logger.info("moodle_solver: using fallback platform detection (URL + DOM probe)") | |
| 584 | + | # Get tab URL to check for Moodle patterns | |
| 585 | + | _tab_url = "" | |
| 586 | + | try: | |
| 587 | + | tabs_result = await send_fn("browser.listTabs", {}) | |
| 588 | + | if isinstance(tabs_result, dict): | |
| 589 | + | for t in tabs_result.get("tabs", []): | |
| 590 | + | if t.get("id") == tab_id: | |
| 591 | + | _tab_url = t.get("url", "") | |
| 592 | + | break | |
| 593 | + | except Exception: | |
| 594 | + | pass | |
| 595 | + | ||
| 596 | + | # Check URL for Moodle quiz patterns | |
| 597 | + | if "/mod/quiz/" in _tab_url: | |
| 598 | + | _platform = "moodle" | |
| 599 | + | if "attempt.php" in _tab_url: | |
| 600 | + | _page_type = "attempt" | |
| 601 | + | elif "summary.php" in _tab_url: | |
| 602 | + | _page_type = "summary" | |
| 603 | + | elif "review.php" in _tab_url: | |
| 604 | + | _page_type = "review" | |
| 605 | + | elif "view.php" in _tab_url: | |
| 606 | + | _page_type = "view" | |
| 607 | + | elif "startattempt.php" in _tab_url: | |
| 608 | + | _page_type = "startattempt" | |
| 609 | + | else: | |
| 610 | + | _page_type = "unknown" | |
| 611 | + | _platform_info = {"platform": _platform, "pageType": _page_type, "url": _tab_url} | |
| 612 | + | else: | |
| 613 | + | # Probe for .que elements as last resort | |
| 614 | + | _moodle_probe = await _read_selector(".que .info", max_len=2000) | |
| 615 | + | if not _moodle_probe: | |
| 616 | + | _moodle_probe = await _read_selector(".que", max_len=10000) | |
| 617 | + | if _moodle_probe: | |
| 618 | + | _platform = "moodle" | |
| 619 | + | _page_type = "attempt" | |
| 620 | + | _platform_info = {"platform": "moodle", "pageType": "attempt", "url": _tab_url} | |
| 621 | + | else: | |
| 622 | + | _platform_info = {"platform": "unknown", "pageType": "unknown", "url": _tab_url} | |
| 623 | + | ||
| 624 | + | logger.info(f"moodle_solver: platform={_platform}, pageType={_page_type}, url={_platform_info.get('url', '?')}") | |
| 625 | + | ||
| 626 | + | # ── Reject unsupported platforms ── | |
| 627 | + | if _platform != "moodle": | |
| 628 | + | _emit({"status": "test_error", "error": "unsupported_platform"}) | |
| 629 | + | await _end_span(_solve_span, success=False, error_message="unsupported_platform") | |
| 630 | + | return ( | |
| 631 | + | "Не удалось определить платформу тестирования как Moodle.\n" | |
| 632 | + | "На данный момент автоматическое прохождение тестов поддерживается " | |
| 633 | + | "только для **Moodle** (mod/quiz).\n\n" | |
| 634 | + | f"Обнаружено: platform={_platform}, URL={_platform_info.get('url', '?')}" | |
| 635 | + | ) | |
| 636 | + | ||
| 637 | + | # ── Navigate from intro/confirmation to attempt page ── | |
| 638 | + | _max_nav_steps = 3 # view → startattempt → attempt (max 3 transitions) | |
| 639 | + | for _nav_step in range(_max_nav_steps): | |
| 640 | + | if _page_type == "attempt": | |
| 641 | + | logger.info("moodle_solver: on attempt page, starting quiz") | |
| 642 | + | break | |
| 643 | + | ||
| 644 | + | if _page_type == "review": | |
| 645 | + | _emit({"status": "test_error", "error": "review_page"}) | |
| 646 | + | return "Тест уже завершён — открыта страница просмотра результатов (review)." | |
| 647 | + | ||
| 648 | + | if _page_type == "summary": | |
| 649 | + | logger.info("moodle_solver: on summary page, will proceed to submit") | |
| 650 | + | break | |
| 651 | + | ||
| 652 | + | # ── Click start/confirm button to navigate forward ── | |
| 653 | + | _btn_text = None | |
| 654 | + | if _page_type == "view": | |
| 655 | + | _btn_text = _platform_info.get("startBtn") | |
| 656 | + | elif _page_type == "startattempt": | |
| 657 | + | _btn_text = _platform_info.get("confirmBtn") | |
| 658 | + | ||
| 659 | + | if not _btn_text: | |
| 660 | + | # Fallback: search interactive elements for start/confirm keywords | |
| 661 | + | text, elems = await _read_page() | |
| 662 | + | _start_keywords = [ | |
| 663 | + | "начать попытку", "пройти тест", "start attempt", | |
| 664 | + | "attempt quiz", "начать тест", "приступить", | |
| 665 | + | "продолжить попытку", "continue attempt", | |
| 666 | + | ] | |
| 667 | + | for el in elems: | |
| 668 | + | el_text = (el.get("text") or "").lower() | |
| 669 | + | if any(kw in el_text for kw in _start_keywords): | |
| 670 | + | _btn_text = (el.get("text") or "").strip() | |
| 671 | + | break | |
| 672 | + | ||
| 673 | + | if _btn_text: | |
| 674 | + | logger.info(f"moodle_solver: clicking '{_btn_text}' (page={_page_type})") | |
| 675 | + | try: | |
| 676 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "text": _btn_text}) | |
| 677 | + | except Exception as e: | |
| 678 | + | logger.warning(f"moodle_solver: click failed: {e}") | |
| 679 | + | # Try generic submit button as last resort | |
| 680 | + | try: | |
| 681 | + | await send_fn("browser.clickElement", { | |
| 682 | + | "tabId": tab_id, | |
| 683 | + | "selector": "input[type='submit'], button[type='submit']", | |
| 684 | + | }) | |
| 685 | + | except Exception: | |
| 686 | + | break | |
| 687 | + | await asyncio.sleep(1.5) | |
| 688 | + | elif _page_type in ("view", "startattempt"): | |
| 689 | + | logger.warning(f"moodle_solver: no button found on {_page_type} page") | |
| 690 | + | break | |
| 691 | + | else: | |
| 692 | + | logger.warning(f"moodle_solver: unexpected Moodle page type: {_page_type}") | |
| 693 | + | break | |
| 694 | + | ||
| 695 | + | # Re-detect after navigation | |
| 696 | + | if _detect_available: | |
| 697 | + | try: | |
| 698 | + | _platform_info = await send_fn("browser.detectQuizPlatform", {"tabId": tab_id}) or {} | |
| 699 | + | _page_type = _platform_info.get("pageType", "unknown") | |
| 700 | + | except Exception: | |
| 701 | + | _page_type = "unknown" | |
| 702 | + | else: | |
| 703 | + | # Fallback: re-check URL | |
| 704 | + | try: | |
| 705 | + | tabs_result = await send_fn("browser.listTabs", {}) | |
| 706 | + | _tab_url = "" | |
| 707 | + | if isinstance(tabs_result, dict): | |
| 708 | + | for t in tabs_result.get("tabs", []): | |
| 709 | + | if t.get("id") == tab_id: | |
| 710 | + | _tab_url = t.get("url", "") | |
| 711 | + | break | |
| 712 | + | if "attempt.php" in _tab_url: | |
| 713 | + | _page_type = "attempt" | |
| 714 | + | elif "summary.php" in _tab_url: | |
| 715 | + | _page_type = "summary" | |
| 716 | + | elif "startattempt.php" in _tab_url: | |
| 717 | + | _page_type = "startattempt" | |
| 718 | + | elif "view.php" in _tab_url: | |
| 719 | + | _page_type = "view" | |
| 720 | + | else: | |
| 721 | + | # Probe for .que | |
| 722 | + | _probe = await _read_selector(".que .info", max_len=2000) | |
| 723 | + | _page_type = "attempt" if _probe else "unknown" | |
| 724 | + | except Exception: | |
| 725 | + | _page_type = "unknown" | |
| 726 | + | logger.info(f"moodle_solver: after nav step {_nav_step + 1}: pageType={_page_type}") | |
| 727 | + | ||
| 728 | + | # ── Handle student name input (if on attempt page with name field) ── | |
| 729 | + | if student_name and _page_type == "attempt": | |
| 730 | + | text, elems = await _read_page() | |
| 731 | + | for el in elems: | |
| 732 | + | el_id = (el.get("id") or "").lower() | |
| 733 | + | el_tag = (el.get("tag") or "").lower() | |
| 734 | + | if el_tag == "input" and any( | |
| 735 | + | k in el_id for k in ["name", "student", "username", "firstname"] | |
| 736 | + | ): | |
| 737 | + | sel = el.get("selector") or f"#{el.get('id', '')}" | |
| 738 | + | await send_fn("browser.typeText", { | |
| 739 | + | "tabId": tab_id, "selector": sel, | |
| 740 | + | "text": student_name, "clearFirst": True, | |
| 741 | + | }) | |
| 742 | + | break | |
| 743 | + | ||
| 744 | + | # ── Step 2: verify we're on a quiz attempt page ────────────────────── | |
| 745 | + | _is_moodle = _page_type in ("attempt", "summary") | |
| 746 | + | if not _is_moodle: | |
| 747 | + | # Last resort: probe for .que elements directly | |
| 748 | + | _moodle_probe = await _read_selector(".que .info", max_len=2000) | |
| 749 | + | if not _moodle_probe: | |
| 750 | + | _moodle_probe = await _read_selector(".que", max_len=10000) | |
| 751 | + | _is_moodle = bool(_moodle_probe) | |
| 752 | + | ||
| 753 | + | if _is_moodle: | |
| 754 | + | _emit({"status": "test_strategy", "framework": "moodle", "answer_type": "structured"}) | |
| 755 | + | logger.info("moodle_solver: Moodle quiz confirmed, starting solver") | |
| 756 | + | else: | |
| 757 | + | _emit({"status": "test_error", "error": "not_on_attempt"}) | |
| 758 | + | await _end_span(_solve_span, success=False, error_message="not_on_attempt") | |
| 759 | + | return ( | |
| 760 | + | "Не удалось перейти на страницу с вопросами теста.\n" | |
| 761 | + | f"Текущая страница: {_page_type} ({_platform_info.get('url', '?')})\n\n" | |
| 762 | + | "Попробуйте открыть тест вручную и запустить решение снова." | |
| 763 | + | ) | |
| 764 | + | ||
| 765 | + | # ── Helper: click Next page button ──────────────────────────────────── | |
| 766 | + | async def _click_next_page() -> bool: | |
| 767 | + | for _moodle_sel in MOODLE_NAV_SELECTORS: | |
| 768 | + | try: | |
| 769 | + | await send_fn("browser.clickElement", { | |
| 770 | + | "tabId": tab_id, "selector": _moodle_sel | |
| 771 | + | }) | |
| 772 | + | return True | |
| 773 | + | except Exception: | |
| 774 | + | pass | |
| 775 | + | for kw in [ | |
| 776 | + | "Следующая страница", "Next page", "Далее", "Next", | |
| 777 | + | "Завершить попытку", "Finish attempt", | |
| 778 | + | ]: | |
| 779 | + | try: | |
| 780 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "text": kw}) | |
| 781 | + | return True | |
| 782 | + | except Exception: | |
| 783 | + | pass | |
| 784 | + | return False | |
| 785 | + | ||
| 786 | + | # ── TRACE MODE: collect HTML of each question without answering ────── | |
| 787 | + | _mode_lo = (mode or "").strip().lower() | |
| 788 | + | _is_trace = _mode_lo in ( | |
| 789 | + | "trace", "tracing", "learn", "learning", | |
| 790 | + | "трассировка", "трассировки", "обучение", "обучения", | |
| 791 | + | ) or "трассир" in _mode_lo or "обучен" in _mode_lo | |
| 792 | + | if _is_trace: | |
| 793 | + | _trace_records: List[dict] = [] | |
| 794 | + | prev_trace_text = "" | |
| 795 | + | stuck_trace = 0 | |
| 796 | + | ||
| 797 | + | for _step in range(MAX_QUESTIONS): | |
| 798 | + | text, elems = await _read_page() | |
| 799 | + | text_lower = text.lower() | |
| 800 | + | ||
| 801 | + | # Detect results / finish page | |
| 802 | + | if _trace_records: | |
| 803 | + | if any(m in text_lower for m in RESULT_MARKERS): | |
| 804 | + | if not re.search(r'(?:вопрос|question)\s*\d+', text_lower): | |
| 805 | + | break | |
| 806 | + | ||
| 807 | + | # Stuck detection | |
| 808 | + | if text.strip() == prev_trace_text: | |
| 809 | + | stuck_trace += 1 | |
| 810 | + | if stuck_trace >= 3: | |
| 811 | + | break | |
| 812 | + | else: | |
| 813 | + | stuck_trace = 0 | |
| 814 | + | prev_trace_text = text.strip() | |
| 815 | + | ||
| 816 | + | # Use getMoodleQuestions if Moodle detected | |
| 817 | + | if _is_moodle: | |
| 818 | + | try: | |
| 819 | + | mq_result = await send_fn("browser.getMoodleQuestions", {"tabId": tab_id}) | |
| 820 | + | mq_list = (mq_result.get("questions", []) | |
| 821 | + | if isinstance(mq_result, dict) else []) | |
| 822 | + | except Exception: | |
| 823 | + | mq_list = [] | |
| 824 | + | ||
| 825 | + | if mq_list: | |
| 826 | + | for q in mq_list: | |
| 827 | + | q_html = await _read_question_html() | |
| 828 | + | _trace_records.append({ | |
| 829 | + | "num": str(len(_trace_records) + 1), | |
| 830 | + | "total": "?", | |
| 831 | + | "text": q.get("qtext", "")[:200], | |
| 832 | + | "qtype": q.get("qtype", "unknown"), | |
| 833 | + | "options": [o.get("text", "") for o in q.get("options", [])], | |
| 834 | + | "selects": len(q.get("selects", [])), | |
| 835 | + | "text_inputs": len(q.get("textInputs", [])), | |
| 836 | + | "html": q_html, | |
| 837 | + | }) | |
| 838 | + | ||
| 839 | + | _emit({"status": "test_tracing", "question": len(_trace_records)}) | |
| 840 | + | ||
| 841 | + | # Click Next | |
| 842 | + | await _click_next_page() | |
| 843 | + | await asyncio.sleep(0.5) | |
| 844 | + | continue | |
| 845 | + | ||
| 846 | + | # Fallback: generic trace | |
| 847 | + | q_html = await _read_question_html() | |
| 848 | + | q_text_val = (await _read_qtext())[:200] or \ | |
| 849 | + | _extract_question_text(text, [], None)[:200] | |
| 850 | + | ||
| 851 | + | _trace_records.append({ | |
| 852 | + | "num": str(len(_trace_records) + 1), | |
| 853 | + | "total": "?", | |
| 854 | + | "text": q_text_val, | |
| 855 | + | "qtype": _detect_signature(text, elems)["name"], | |
| 856 | + | "options": [], | |
| 857 | + | "selects": 0, | |
| 858 | + | "text_inputs": 0, | |
| 859 | + | "html": q_html, | |
| 860 | + | }) | |
| 861 | + | ||
| 862 | + | _emit({"status": "test_tracing", "question": len(_trace_records)}) | |
| 863 | + | ||
| 864 | + | # Click Next | |
| 865 | + | await _click_next_page() | |
| 866 | + | await asyncio.sleep(0.5) | |
| 867 | + | ||
| 868 | + | # Build trace report | |
| 869 | + | lines_out: List[str] = [ | |
| 870 | + | f"## Трассировка теста — {len(_trace_records)} вопросов", | |
| 871 | + | "", | |
| 872 | + | ] | |
| 873 | + | for rec in _trace_records: | |
| 874 | + | lines_out.append(f"### Вопрос {rec['num']}") | |
| 875 | + | lines_out.append(f"**Тип:** `{rec.get('qtype', '?')}`") | |
| 876 | + | if rec.get("text"): | |
| 877 | + | lines_out.append(f"**Текст:** {rec['text']}") | |
| 878 | + | if rec.get("options"): | |
| 879 | + | lines_out.append(f"**Варианты:** {', '.join(rec['options'][:10])}") | |
| 880 | + | if rec.get("selects"): | |
| 881 | + | lines_out.append(f"**Выпадающих списков:** {rec['selects']}") | |
| 882 | + | if rec.get("text_inputs"): | |
| 883 | + | lines_out.append(f"**Текстовых полей:** {rec['text_inputs']}") | |
| 884 | + | if rec.get("html"): | |
| 885 | + | lines_out.append(f"\n```html\n{rec['html']}\n```") | |
| 886 | + | else: | |
| 887 | + | lines_out.append("*HTML не загружен (обновите расширение)*") | |
| 888 | + | lines_out.append("") | |
| 889 | + | ||
| 890 | + | _emit({"status": "test_complete", "answered": 0, "mode": "trace"}) | |
| 891 | + | await _end_span(_solve_span, success=True, metadata_updates={ | |
| 892 | + | "mode": "trace", "questions_total": len(_trace_records), | |
| 893 | + | }) | |
| 894 | + | return "\n".join(lines_out) | |
| 895 | + | ||
| 896 | + | # ── Helper: answer one question (Moodle structured) ────────────────── | |
| 897 | + | async def _answer_moodle_question(q: dict, q_idx: int, q_total_str: str): | |
| 898 | + | """Answer a single Moodle question using structured data from getMoodleQuestions.""" | |
| 899 | + | nonlocal answered, _total_simulated_sec | |
| 900 | + | ||
| 901 | + | qtext = q.get("qtext", "") | |
| 902 | + | qtype = q.get("qtype", "unknown") | |
| 903 | + | options = q.get("options", []) | |
| 904 | + | selects = q.get("selects", []) | |
| 905 | + | ||
| 906 | + | # Start MOODLE_QUESTION span | |
| 907 | + | q_num_val = str(q.get("qno", q_idx + 1)) | |
| 908 | + | _q_span = await _start_span(OperationType.MOODLE_QUESTION, { | |
| 909 | + | "question_num": q_num_val, | |
| 910 | + | "question_type": qtype, | |
| 911 | + | "question_text": qtext[:200], | |
| 912 | + | }) | |
| 913 | + | t_inputs = q.get("textInputs", []) | |
| 914 | + | ddwtos_places = q.get("ddwtosPlaces", []) | |
| 915 | + | is_mult = q.get("multiple", False) | |
| 916 | + | ||
| 917 | + | # numerical: radios with _unit in name are unit selectors, not answer options | |
| 918 | + | unit_options = [] | |
| 919 | + | if qtype == "numerical" and options: | |
| 920 | + | unit_options = [o for o in options if "_unit" in o.get("selector", "")] | |
| 921 | + | options = [o for o in options if "_unit" not in o.get("selector", "")] | |
| 922 | + | ||
| 923 | + | # Use real question number from Moodle (qno), fallback to sequential index | |
| 924 | + | q_num = str(q.get("qno", q_idx + 1)) | |
| 925 | + | # Use total from navigation block if available | |
| 926 | + | _moodle_total = q.get("totalQuestions", 0) | |
| 927 | + | if _moodle_total and _moodle_total > 0: | |
| 928 | + | q_total_str = str(_moodle_total) | |
| 929 | + | q_short = qtext[:80] if qtext else f"Вопрос {q_num}" | |
| 930 | + | ||
| 931 | + | _emit({ | |
| 932 | + | "status": "test_thinking", | |
| 933 | + | "question": int(q_num), | |
| 934 | + | "total": int(q_total_str) if q_total_str != "?" else 0, | |
| 935 | + | "question_text": q_short, | |
| 936 | + | "human_delay_sec": 0, | |
| 937 | + | }) | |
| 938 | + | ||
| 939 | + | # Build LLM prompt | |
| 940 | + | opt_texts = [o.get("text", "") for o in options] | |
| 941 | + | prompt = f"Вопрос {q_num} из {q_total_str}:\n{qtext}\n" | |
| 942 | + | if opt_texts: | |
| 943 | + | label = "Чекбоксы (несколько верных)" if is_mult else "Варианты ответа" | |
| 944 | + | opts_str = "\n".join(f" {i+1}. {t}" for i, t in enumerate(opt_texts)) | |
| 945 | + | prompt += f"\n{label}:\n{opts_str}\n" | |
| 946 | + | if selects: | |
| 947 | + | prompt += "\nВыпадающие списки:\n" | |
| 948 | + | for si, sel_data in enumerate(selects): | |
| 949 | + | stem = sel_data.get("stem", "") | |
| 950 | + | sel_opts = [o["text"] for o in sel_data.get("options", []) if o.get("text")] | |
| 951 | + | prompt += f" Список {si+1}{' ('+stem+')' if stem else ''}: {', '.join(sel_opts)}\n" | |
| 952 | + | if t_inputs and not opt_texts and not selects: | |
| 953 | + | prompt += "\nЕсть поле для ввода текстового ответа.\n" | |
| 954 | + | ||
| 955 | + | prompt += ( | |
| 956 | + | "\nИнструкция:\n" | |
| 957 | + | "- Варианты → верни ТОЛЬКО номер(а) через запятую (например: 2 или 1,3)\n" | |
| 958 | + | "- Текстовый ответ → верни ТЕКСТ: ответ\n" | |
| 959 | + | "- Без пояснений." | |
| 960 | + | ) | |
| 961 | + | ||
| 962 | + | # Human delay | |
| 963 | + | if _override_total is not None: | |
| 964 | + | _est = int(q_total_str) if q_total_str != "?" else MAX_QUESTIONS | |
| 965 | + | _per = _override_total / max(_est, 1) | |
| 966 | + | delay = random.uniform(max(0.5, _per * 0.85), _per * 1.15) | |
| 967 | + | else: | |
| 968 | + | delay = _human_delay_sec(qtext, opt_texts or None) | |
| 969 | + | _total_simulated_sec += delay | |
| 970 | + | _emit({ | |
| 971 | + | "status": "test_thinking", | |
| 972 | + | "question": int(q_num), | |
| 973 | + | "total": int(q_total_str) if q_total_str != "?" else 0, | |
| 974 | + | "question_text": q_short, | |
| 975 | + | "human_delay_sec": round(delay, 1), | |
| 976 | + | }) | |
| 977 | + | await asyncio.sleep(delay) | |
| 978 | + | ||
| 979 | + | # ── ddwtos: Drag and Drop into Text ────────────────────────────── | |
| 980 | + | if ddwtos_places: | |
| 981 | + | # Group places by group — each group has its own choice set | |
| 982 | + | groups: Dict[str, list] = {} | |
| 983 | + | for p in ddwtos_places: | |
| 984 | + | g = p.get("group", "1") | |
| 985 | + | if g not in groups: | |
| 986 | + | groups[g] = [] | |
| 987 | + | groups[g].append(p) | |
| 988 | + | ||
| 989 | + | # Build prompt showing blanks and choices per group | |
| 990 | + | prompt_ddwtos = f"Вопрос {q_num} из {q_total_str}:\n{qtext}\n\n" | |
| 991 | + | prompt_ddwtos += f"В тексте {len(ddwtos_places)} пропусков. Нужно заполнить каждый пропуск словом из списка.\n\n" | |
| 992 | + | ||
| 993 | + | for g_id, places in sorted(groups.items()): | |
| 994 | + | choices = places[0].get("choices", []) | |
| 995 | + | choice_texts = [c["text"] for c in sorted(choices, key=lambda c: c["value"])] | |
| 996 | + | place_nums = [str(p["place"]) for p in places] | |
| 997 | + | prompt_ddwtos += f"Пропуски {', '.join(place_nums)} — варианты: {', '.join(f'{i+1}. {t}' for i, t in enumerate(choice_texts))}\n" | |
| 998 | + | ||
| 999 | + | prompt_ddwtos += ( | |
| 1000 | + | f"\nВерни {len(ddwtos_places)} чисел через запятую — номер варианта для каждого пропуска " | |
| 1001 | + | f"по порядку (пропуск 1, пропуск 2, ...).\n" | |
| 1002 | + | f"Без пояснений. Пример: 1,2,1,2,2,1,1" | |
| 1003 | + | ) | |
| 1004 | + | ||
| 1005 | + | ddwtos_answer = await _ask_llm(prompt_ddwtos) | |
| 1006 | + | logger.info(f"moodle_solver Q{q_num} (ddwtos): LLM → {ddwtos_answer!r}") | |
| 1007 | + | ||
| 1008 | + | nums = re.findall(r'\d+', ddwtos_answer) | |
| 1009 | + | ddwtos_results = [] | |
| 1010 | + | for pi, (place, num_str) in enumerate(zip(ddwtos_places, nums)): | |
| 1011 | + | try: | |
| 1012 | + | choice_idx = int(num_str) - 1 | |
| 1013 | + | choices = sorted(place.get("choices", []), key=lambda c: c["value"]) | |
| 1014 | + | if 0 <= choice_idx < len(choices): | |
| 1015 | + | choice_val = str(choices[choice_idx]["value"]) | |
| 1016 | + | choice_text = choices[choice_idx]["text"] | |
| 1017 | + | sel = place.get("selector", "") | |
| 1018 | + | if sel: | |
| 1019 | + | await send_fn("browser.setInputValue", { | |
| 1020 | + | "tabId": tab_id, "selector": sel, "value": choice_val, | |
| 1021 | + | }) | |
| 1022 | + | ddwtos_results.append(choice_text) | |
| 1023 | + | logger.debug(f"moodle_solver Q{q_num}: place {place['place']} → '{choice_text}' (value={choice_val})") | |
| 1024 | + | except Exception as e: | |
| 1025 | + | logger.warning(f"moodle_solver Q{q_num}: setInputValue failed for place {pi+1}: {e}") | |
| 1026 | + | ||
| 1027 | + | chosen_answer = "; ".join(ddwtos_results) | |
| 1028 | + | answer_raw = chosen_answer | |
| 1029 | + | ||
| 1030 | + | answered += 1 | |
| 1031 | + | _final = chosen_answer.strip() or ddwtos_answer.strip() | |
| 1032 | + | _emit({ | |
| 1033 | + | "status": "test_answered", | |
| 1034 | + | "question": int(q_num), | |
| 1035 | + | "total": int(q_total_str) if q_total_str != "?" else 0, | |
| 1036 | + | "answer": _final[:60], | |
| 1037 | + | "question_text": q_short, | |
| 1038 | + | }) | |
| 1039 | + | _qa_records.append({ | |
| 1040 | + | "num": q_num, "total": q_total_str, | |
| 1041 | + | "text": q_short, | |
| 1042 | + | "answer": _final, | |
| 1043 | + | "delay_sec": round(delay, 1), | |
| 1044 | + | }) | |
| 1045 | + | await _end_span(_q_span, success=True, metadata_updates={ | |
| 1046 | + | "chosen_answer": _final[:100], "delay_sec": round(delay, 1), | |
| 1047 | + | }) | |
| 1048 | + | return | |
| 1049 | + | ||
| 1050 | + | # Ask LLM (skip generic call for select-only questions — they use own prompt) | |
| 1051 | + | if opt_texts or t_inputs or not selects: | |
| 1052 | + | answer_raw = await _ask_llm(prompt) | |
| 1053 | + | logger.info(f"moodle_solver Q{q_num} ({qtype}): LLM → {answer_raw!r}") | |
| 1054 | + | else: | |
| 1055 | + | answer_raw = "" | |
| 1056 | + | ||
| 1057 | + | # Apply answer | |
| 1058 | + | chosen_answer = "" | |
| 1059 | + | ||
| 1060 | + | # Determine if this is a text-input question: | |
| 1061 | + | # - has text inputs AND no option radios (after filtering units for numerical) | |
| 1062 | + | # - OR LLM returned "ТЕКСТ:" AND question actually has text inputs | |
| 1063 | + | _is_text_answer = ( | |
| 1064 | + | (t_inputs and not opt_texts and not selects) | |
| 1065 | + | or (answer_raw.upper().startswith("ТЕКСТ:") and t_inputs and not opt_texts) | |
| 1066 | + | ) | |
| 1067 | + | if _is_text_answer: | |
| 1068 | + | # Текстовый ввод (shortanswer, numerical, essay) | |
| 1069 | + | text_val = ( | |
| 1070 | + | answer_raw[6:].strip() | |
| 1071 | + | if answer_raw.upper().startswith("ТЕКСТ:") | |
| 1072 | + | else answer_raw.strip() | |
| 1073 | + | ) | |
| 1074 | + | # numerical: extract only the number (strip units, text) | |
| 1075 | + | if qtype == "numerical" and text_val: | |
| 1076 | + | _num_match = re.search(r'-?\d+(?:[.,]\d+)?', text_val) | |
| 1077 | + | if _num_match: | |
| 1078 | + | text_val = _num_match.group(0).replace(',', '.') | |
| 1079 | + | logger.debug(f"moodle_solver Q{q_num}: numerical cleaned → '{text_val}'") | |
| 1080 | + | if text_val and t_inputs: | |
| 1081 | + | try: | |
| 1082 | + | await send_fn("browser.typeText", { | |
| 1083 | + | "tabId": tab_id, "selector": t_inputs[0]["selector"], | |
| 1084 | + | "text": text_val, "clearFirst": True, | |
| 1085 | + | }) | |
| 1086 | + | chosen_answer = text_val | |
| 1087 | + | except Exception as e: | |
| 1088 | + | logger.warning(f"moodle_solver Q{q_num}: typeText failed: {e}") | |
| 1089 | + | chosen_answer = f"[ошибка ввода] {text_val}" | |
| 1090 | + | # numerical: also select first unit radio if available | |
| 1091 | + | if unit_options: | |
| 1092 | + | try: | |
| 1093 | + | u_sel = unit_options[0].get("selector", "") | |
| 1094 | + | if u_sel: | |
| 1095 | + | await send_fn("browser.clickElement", { | |
| 1096 | + | "tabId": tab_id, "selector": u_sel, | |
| 1097 | + | }) | |
| 1098 | + | logger.debug(f"moodle_solver Q{q_num}: clicked unit option '{unit_options[0].get('text', '')}'") | |
| 1099 | + | except Exception as e: | |
| 1100 | + | logger.warning(f"moodle_solver Q{q_num}: unit click failed: {e}") | |
| 1101 | + | else: | |
| 1102 | + | # Strip "ТЕКСТ:" prefix if LLM mistakenly used it for a radio question | |
| 1103 | + | if answer_raw.upper().startswith("ТЕКСТ:"): | |
| 1104 | + | answer_raw = answer_raw[6:].strip() | |
| 1105 | + | # Radio/checkbox (multichoice, truefalse, multianswer) | |
| 1106 | + | if opt_texts: | |
| 1107 | + | nums = re.findall(r'\d+', answer_raw) | |
| 1108 | + | if not is_mult and nums: | |
| 1109 | + | nums = nums[:1] | |
| 1110 | + | ||
| 1111 | + | # Fallback: letters | |
| 1112 | + | if not nums: | |
| 1113 | + | _letter_map = {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, | |
| 1114 | + | 'а': 1, 'б': 2, 'в': 3, 'г': 4, 'д': 5} | |
| 1115 | + | _letters = re.findall(r'\b([a-eа-д])\b', answer_raw, re.IGNORECASE) | |
| 1116 | + | for _let in _letters: | |
| 1117 | + | n = _letter_map.get(_let.lower(), 0) | |
| 1118 | + | if n and str(n) not in nums: | |
| 1119 | + | nums.append(str(n)) | |
| 1120 | + | if not is_mult and nums: | |
| 1121 | + | nums = nums[:1] | |
| 1122 | + | ||
| 1123 | + | # Fallback: text match | |
| 1124 | + | if not nums and answer_raw.strip(): | |
| 1125 | + | _ans_lo = answer_raw.strip().lower() | |
| 1126 | + | for _oi, _ot in enumerate(opt_texts): | |
| 1127 | + | if _ot.lower().strip() in _ans_lo or _ans_lo in _ot.lower().strip(): | |
| 1128 | + | nums = [str(_oi + 1)] | |
| 1129 | + | break | |
| 1130 | + | ||
| 1131 | + | for num_str in nums: | |
| 1132 | + | idx = int(num_str) - 1 | |
| 1133 | + | if 0 <= idx < len(options): | |
| 1134 | + | opt = options[idx] | |
| 1135 | + | sel = opt.get("selector", "") | |
| 1136 | + | opt_text = opt.get("text", "") | |
| 1137 | + | _clicked = False | |
| 1138 | + | # Try selector + text (clickElement uses text as fallback) | |
| 1139 | + | if sel: | |
| 1140 | + | try: | |
| 1141 | + | res = await send_fn("browser.clickElement", { | |
| 1142 | + | "tabId": tab_id, "selector": sel, "text": opt_text | |
| 1143 | + | }) | |
| 1144 | + | _clicked = True | |
| 1145 | + | logger.debug(f"moodle_solver Q{q_num}: click OK sel='{sel}' → {res}") | |
| 1146 | + | except Exception as e: | |
| 1147 | + | logger.warning(f"moodle_solver Q{q_num}: click by selector failed sel='{sel}': {e}") | |
| 1148 | + | # Fallback: click by text only | |
| 1149 | + | if not _clicked and opt_text: | |
| 1150 | + | try: | |
| 1151 | + | res = await send_fn("browser.clickElement", { | |
| 1152 | + | "tabId": tab_id, "text": opt_text | |
| 1153 | + | }) | |
| 1154 | + | _clicked = True | |
| 1155 | + | logger.debug(f"moodle_solver Q{q_num}: click OK text='{opt_text[:50]}' → {res}") | |
| 1156 | + | except Exception as e: | |
| 1157 | + | logger.warning(f"moodle_solver Q{q_num}: click failed for '{opt_text[:50]}': {e}") | |
| 1158 | + | if _clicked: | |
| 1159 | + | chosen_answer += opt_text + "; " | |
| 1160 | + | ||
| 1161 | + | # Select dropdowns (match, ddmatch, gapselect, multianswer inline) | |
| 1162 | + | # Независимо от options — multianswer может иметь и то, и другое | |
| 1163 | + | if selects: | |
| 1164 | + | if qtype in ("matching", "ddmatch"): | |
| 1165 | + | common_opts = [o["text"] for o in selects[0].get("options", []) if o.get("text") and o.get("value", "0") != "0"] | |
| 1166 | + | n_sels = len(selects) | |
| 1167 | + | prompt_sel = ( | |
| 1168 | + | f"Задание на сопоставление:\n{qtext}\n\n" | |
| 1169 | + | f"Нужно сопоставить {n_sels} строк с вариантами из списка.\n\n" | |
| 1170 | + | "СТРОКИ:\n" | |
| 1171 | + | ) | |
| 1172 | + | for si, sel_data in enumerate(selects): | |
| 1173 | + | stem = sel_data.get("stem", f"Строка {si+1}") | |
| 1174 | + | prompt_sel += f" [{si+1}] {stem}\n" | |
| 1175 | + | prompt_sel += "\nВАРИАНТЫ (номер → текст):\n" | |
| 1176 | + | for oi, ot in enumerate(common_opts): | |
| 1177 | + | prompt_sel += f" {oi+1}. {ot}\n" | |
| 1178 | + | prompt_sel += ( | |
| 1179 | + | f"\nОТВЕТ: выведи ТОЛЬКО {n_sels} чисел через запятую — " | |
| 1180 | + | f"номер варианта для каждой строки по порядку.\n" | |
| 1181 | + | f"Формат: число,число,число (без пробелов, без пояснений).\n" | |
| 1182 | + | f"Пример для 3 строк: 2,1,3" | |
| 1183 | + | ) | |
| 1184 | + | else: # gapselect / multianswer inline | |
| 1185 | + | prompt_sel = f"Текст: {qtext}\n\nЗаполните пропуски:\n" | |
| 1186 | + | for si, sel_data in enumerate(selects): | |
| 1187 | + | stem = sel_data.get("stem", "") | |
| 1188 | + | sel_opts = [o["text"] for o in sel_data.get("options", []) if o.get("text") and o.get("value", "") != ""] | |
| 1189 | + | prompt_sel += f"\nПропуск {si+1}{' ('+stem+')' if stem else ''}:\n" | |
| 1190 | + | for oi, ot in enumerate(sel_opts): | |
| 1191 | + | prompt_sel += f" {oi+1}. {ot}\n" | |
| 1192 | + | prompt_sel += "\nИнструкция: верни номера через запятую для каждого пропуска. Пример: 1,3" | |
| 1193 | + | ||
| 1194 | + | logger.info(f"moodle_solver Q{q_num} ({qtype}): selects prompt:\n{prompt_sel}") | |
| 1195 | + | sel_answer_raw = await _ask_llm(prompt_sel) | |
| 1196 | + | logger.info(f"moodle_solver Q{q_num} ({qtype}): LLM selects → {sel_answer_raw!r}") | |
| 1197 | + | ||
| 1198 | + | # Extract answer numbers robustly: | |
| 1199 | + | # Prefer comma-separated sequence (e.g. "3,5,2,4,7,1,8,9,6") | |
| 1200 | + | # Fallback: all numbers in text | |
| 1201 | + | _csv_match = re.search(r'(\d+(?:\s*,\s*\d+)+)', sel_answer_raw) | |
| 1202 | + | if _csv_match: | |
| 1203 | + | nums = re.findall(r'\d+', _csv_match.group(1)) | |
| 1204 | + | else: | |
| 1205 | + | nums = re.findall(r'\d+', sel_answer_raw) | |
| 1206 | + | sel_results = [] | |
| 1207 | + | for si, (sel_data, num_str) in enumerate(zip(selects, nums)): | |
| 1208 | + | try: | |
| 1209 | + | idx = int(num_str) - 1 | |
| 1210 | + | if qtype in ("matching", "ddmatch"): | |
| 1211 | + | valid_opts = [o for o in sel_data.get("options", []) if o.get("value", "0") != "0"] | |
| 1212 | + | else: | |
| 1213 | + | valid_opts = [o for o in sel_data.get("options", []) if o.get("value", "") != ""] | |
| 1214 | + | if 0 <= idx < len(valid_opts): | |
| 1215 | + | opt_value = valid_opts[idx]["value"] | |
| 1216 | + | opt_text = valid_opts[idx]["text"] | |
| 1217 | + | sel_sel = sel_data.get("selector", "") | |
| 1218 | + | if sel_sel: | |
| 1219 | + | res = await send_fn("browser.selectOption", { | |
| 1220 | + | "tabId": tab_id, "selector": sel_sel, "value": opt_value | |
| 1221 | + | }) | |
| 1222 | + | sel_results.append(opt_text) | |
| 1223 | + | logger.info(f"moodle_solver Q{q_num}: select {si+1} OK → '{opt_text}' (value={opt_value}, result={res})") | |
| 1224 | + | except Exception as e: | |
| 1225 | + | logger.warning(f"moodle_solver Q{q_num}: selectOption failed for select {si+1}: {e}") | |
| 1226 | + | ||
| 1227 | + | if sel_results: | |
| 1228 | + | chosen_answer += "; ".join(sel_results) | |
| 1229 | + | ||
| 1230 | + | answered += 1 | |
| 1231 | + | _final = (chosen_answer.rstrip("; ") if chosen_answer else answer_raw).strip() | |
| 1232 | + | _emit({ | |
| 1233 | + | "status": "test_answered", | |
| 1234 | + | "question": int(q_num), | |
| 1235 | + | "total": int(q_total_str) if q_total_str != "?" else 0, | |
| 1236 | + | "answer": _final[:60], | |
| 1237 | + | "question_text": q_short, | |
| 1238 | + | }) | |
| 1239 | + | _qa_records.append({ | |
| 1240 | + | "num": q_num, "total": q_total_str, | |
| 1241 | + | "text": q_short, | |
| 1242 | + | "answer": _final, | |
| 1243 | + | "delay_sec": round(delay, 1), | |
| 1244 | + | }) | |
| 1245 | + | await _end_span(_q_span, success=True, metadata_updates={ | |
| 1246 | + | "chosen_answer": _final[:100], "delay_sec": round(delay, 1), | |
| 1247 | + | }) | |
| 1248 | + | ||
| 1249 | + | # ── Step 3: question loop ───────────────────────────────────────────── | |
| 1250 | + | prev_text = "" | |
| 1251 | + | stuck_count = 0 | |
| 1252 | + | _summary_fix_attempts = 0 | |
| 1253 | + | _MAX_SUMMARY_FIX_ATTEMPTS = 2 | |
| 1254 | + | ||
| 1255 | + | for _page in range(MAX_QUESTIONS): | |
| 1256 | + | text, elems = await _read_page() | |
| 1257 | + | text_lower = text.lower() | |
| 1258 | + | ||
| 1259 | + | # ── Detect results / finish page ────────────────────────────── | |
| 1260 | + | _strong_score = bool(re.search( | |
| 1261 | + | r'(?:ваш результат|your (?:score|grade)|правильно на|итог)\s*:?\s*\d+', | |
| 1262 | + | text_lower | |
| 1263 | + | )) | |
| 1264 | + | if answered > 0 or _strong_score: | |
| 1265 | + | if any(m in text_lower for m in RESULT_MARKERS): | |
| 1266 | + | if not re.search(r'(?:вопрос|question)\s*\d+', text_lower): | |
| 1267 | + | _results_text = text | |
| 1268 | + | break | |
| 1269 | + | ||
| 1270 | + | # ── Stuck detection ─────────────────────────────────────────── | |
| 1271 | + | if text.strip() == prev_text: | |
| 1272 | + | stuck_count += 1 | |
| 1273 | + | if stuck_count >= 3: | |
| 1274 | + | _qa_records.append({ | |
| 1275 | + | "num": str(answered + 1), "total": "?", | |
| 1276 | + | "text": "[застрял — страница не меняется, прерываю]", | |
| 1277 | + | "answer": "", "delay_sec": 0.0, | |
| 1278 | + | }) | |
| 1279 | + | break | |
| 1280 | + | else: | |
| 1281 | + | stuck_count = 0 | |
| 1282 | + | prev_text = text.strip() | |
| 1283 | + | ||
| 1284 | + | # ── MOODLE PATH: use getMoodleQuestions ─────────────────────── | |
| 1285 | + | if _is_moodle: | |
| 1286 | + | try: | |
| 1287 | + | mq_result = await send_fn("browser.getMoodleQuestions", {"tabId": tab_id}) | |
| 1288 | + | mq_list = (mq_result.get("questions", []) | |
| 1289 | + | if isinstance(mq_result, dict) else []) | |
| 1290 | + | except Exception as e: | |
| 1291 | + | logger.warning(f"moodle_solver: getMoodleQuestions failed: {e}") | |
| 1292 | + | mq_list = [] | |
| 1293 | + | ||
| 1294 | + | if mq_list: | |
| 1295 | + | q_total_str = "?" | |
| 1296 | + | # Try to determine total from page title | |
| 1297 | + | _title_m = re.search( | |
| 1298 | + | r'страница\s+\d+\s+из\s+(\d+)', text_lower | |
| 1299 | + | ) | |
| 1300 | + | ||
| 1301 | + | # Detect adaptive mode: questions have per-question Check buttons | |
| 1302 | + | _is_adaptive = any( | |
| 1303 | + | q.get("checkButtonSelector") for q in mq_list | |
| 1304 | + | ) | |
| 1305 | + | ||
| 1306 | + | if _is_adaptive: | |
| 1307 | + | # ── ADAPTIVE MODE: answer + check each question individually ── | |
| 1308 | + | logger.info(f"moodle_solver: adaptive mode detected, {len(mq_list)} questions") | |
| 1309 | + | ||
| 1310 | + | for q in mq_list: | |
| 1311 | + | q_state = q.get("state", "notyetanswered") | |
| 1312 | + | check_sel = q.get("checkButtonSelector", "") | |
| 1313 | + | q_num_adp = q.get("qno", 0) | |
| 1314 | + | ||
| 1315 | + | # Skip already correctly answered questions | |
| 1316 | + | if q_state == "correct": | |
| 1317 | + | logger.debug(f"moodle_solver Q{q_num_adp}: already correct, skipping") | |
| 1318 | + | continue | |
| 1319 | + | ||
| 1320 | + | # Answer the question | |
| 1321 | + | await _answer_moodle_question(q, answered, q_total_str) | |
| 1322 | + | ||
| 1323 | + | # Click Check button to register the answer | |
| 1324 | + | if check_sel: | |
| 1325 | + | try: | |
| 1326 | + | await send_fn("browser.clickElement", { | |
| 1327 | + | "tabId": tab_id, "selector": check_sel, | |
| 1328 | + | }) | |
| 1329 | + | logger.info(f"moodle_solver Q{q_num_adp}: clicked Check button") | |
| 1330 | + | except Exception as _chk_err: | |
| 1331 | + | logger.warning(f"moodle_solver Q{q_num_adp}: Check click failed: {_chk_err}") | |
| 1332 | + | continue | |
| 1333 | + | ||
| 1334 | + | # Wait for page reload after Check | |
| 1335 | + | await asyncio.sleep(2.0) | |
| 1336 | + | ||
| 1337 | + | # Re-read questions to get feedback | |
| 1338 | + | try: | |
| 1339 | + | _upd_mq = await send_fn("browser.getMoodleQuestions", {"tabId": tab_id}) | |
| 1340 | + | _upd_list = (_upd_mq.get("questions", []) | |
| 1341 | + | if isinstance(_upd_mq, dict) else []) | |
| 1342 | + | except Exception: | |
| 1343 | + | _upd_list = [] | |
| 1344 | + | ||
| 1345 | + | # Find this question in updated list | |
| 1346 | + | _upd_q = next( | |
| 1347 | + | (uq for uq in _upd_list if uq.get("qno") == q_num_adp), | |
| 1348 | + | None, | |
| 1349 | + | ) | |
| 1350 | + | ||
| 1351 | + | if _upd_q: | |
| 1352 | + | _new_state = _upd_q.get("state", "") | |
| 1353 | + | _fb = _upd_q.get("feedbackText", "") | |
| 1354 | + | _corr = _upd_q.get("correctnessText", "") | |
| 1355 | + | logger.info( | |
| 1356 | + | f"moodle_solver Q{q_num_adp}: after Check — " | |
| 1357 | + | f"state={_new_state}, correctness='{_corr}', " | |
| 1358 | + | f"feedback='{_fb[:100]}'" | |
| 1359 | + | ) | |
| 1360 | + | ||
| 1361 | + | # Determine if retry needed using correctnessText | |
| 1362 | + | # (state from .que class is unreliable in adaptive mode) | |
| 1363 | + | _corr_lo = _corr.lower() | |
| 1364 | + | _needs_retry = ( | |
| 1365 | + | "неверн" in _corr_lo | |
| 1366 | + | or "incorrect" in _corr_lo | |
| 1367 | + | or "частичн" in _corr_lo | |
| 1368 | + | or "partial" in _corr_lo | |
| 1369 | + | or _new_state in ("incorrect", "partiallycorrect") | |
| 1370 | + | ) | |
| 1371 | + | ||
| 1372 | + | if _needs_retry: | |
| 1373 | + | _retry_qtype = _upd_q.get("qtype", q.get("qtype", "unknown")) | |
| 1374 | + | logger.info(f"moodle_solver Q{q_num_adp}: retrying (correctness='{_corr}', qtype={_retry_qtype})") | |
| 1375 | + | ||
| 1376 | + | _retry_qtext = _upd_q.get("qtext", q.get("qtext", "")) | |
| 1377 | + | _retry_opts = _upd_q.get("options", q.get("options", [])) | |
| 1378 | + | _retry_ddwtos = _upd_q.get("ddwtosPlaces", q.get("ddwtosPlaces", [])) | |
| 1379 | + | _retry_selects = _upd_q.get("selects", []) | |
| 1380 | + | ||
| 1381 | + | # ── SMART FLIP: 2-option questions ── | |
| 1382 | + | if _retry_opts and len(_retry_opts) == 2 and not _upd_q.get("multiple") and not _retry_ddwtos: | |
| 1383 | + | _unchecked = [o for o in _retry_opts if not o.get("checked")] | |
| 1384 | + | if _unchecked: | |
| 1385 | + | _flip_opt = _unchecked[0] | |
| 1386 | + | _flip_sel = _flip_opt.get("selector", "") | |
| 1387 | + | if _flip_sel: | |
| 1388 | + | try: | |
| 1389 | + | await send_fn("browser.clickElement", { | |
| 1390 | + | "tabId": tab_id, "selector": _flip_sel, | |
| 1391 | + | }) | |
| 1392 | + | logger.info(f"moodle_solver Q{q_num_adp}: smart flip → '{_flip_opt.get('text', '')}'") | |
| 1393 | + | except Exception as _re: | |
| 1394 | + | logger.warning(f"moodle_solver Q{q_num_adp}: smart flip click failed: {_re}") | |
| 1395 | + | # Click Check after flip | |
| 1396 | + | _rc_sel = _upd_q.get("checkButtonSelector", check_sel) | |
| 1397 | + | if _rc_sel: | |
| 1398 | + | try: | |
| 1399 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "selector": _rc_sel}) | |
| 1400 | + | logger.info(f"moodle_solver Q{q_num_adp}: clicked Check after smart flip") | |
| 1401 | + | await asyncio.sleep(2.0) | |
| 1402 | + | except Exception as _re: | |
| 1403 | + | logger.warning(f"moodle_solver Q{q_num_adp}: Check after flip failed: {_re}") | |
| 1404 | + | ||
| 1405 | + | # ── DDWTOS RETRY (single attempt) ── | |
| 1406 | + | elif _retry_ddwtos: | |
| 1407 | + | _wrong_places = [p for p in _retry_ddwtos if p.get("isCorrect") is not True] | |
| 1408 | + | _correct_places = [p for p in _retry_ddwtos if p.get("isCorrect") is True] | |
| 1409 | + | ||
| 1410 | + | if _wrong_places and _correct_places: | |
| 1411 | + | # Per-blank feedback available — retry only wrong blanks | |
| 1412 | + | logger.info( | |
| 1413 | + | f"moodle_solver Q{q_num_adp}: ddwtos {len(_correct_places)} correct, " | |
| 1414 | + | f"{len(_wrong_places)} wrong" | |
| 1415 | + | ) | |
| 1416 | + | _d_prompt = ( | |
| 1417 | + | f"Вопрос {q_num_adp}:\n{_retry_qtext}\n\n" | |
| 1418 | + | f"Некоторые пропуски заполнены правильно, а некоторые — нет.\n" | |
| 1419 | + | ) | |
| 1420 | + | for p in _correct_places: | |
| 1421 | + | choices = sorted(p.get("choices", []), key=lambda c: c["value"]) | |
| 1422 | + | cur_val = p.get("currentValue", "") | |
| 1423 | + | cur_text = next((c["text"] for c in choices if str(c["value"]) == str(cur_val)), "?") | |
| 1424 | + | _d_prompt += f"Пропуск {p['place']}: ✓ {cur_text} (верно)\n" | |
| 1425 | + | _d_prompt += "\nИсправь только НЕВЕРНЫЕ пропуски:\n" | |
| 1426 | + | for p in _wrong_places: | |
| 1427 | + | choices = sorted(p.get("choices", []), key=lambda c: c["value"]) | |
| 1428 | + | choice_texts = [c["text"] for c in choices] | |
| 1429 | + | cur_val = p.get("currentValue", "") | |
| 1430 | + | cur_text = next((c["text"] for c in choices if str(c["value"]) == str(cur_val)), "?") | |
| 1431 | + | _d_prompt += f"Пропуск {p['place']}: ✗ было '{cur_text}' — варианты: {', '.join(f'{i+1}. {t}' for i, t in enumerate(choice_texts))}\n" | |
| 1432 | + | _d_prompt += ( | |
| 1433 | + | f"\nВерни {len(_wrong_places)} чисел через запятую — номер варианта для каждого неверного пропуска.\n" | |
| 1434 | + | f"Без пояснений." | |
| 1435 | + | ) | |
| 1436 | + | _target_places = _wrong_places | |
| 1437 | + | else: | |
| 1438 | + | # No per-blank feedback — retry all blanks | |
| 1439 | + | _d_groups: Dict[str, list] = {} | |
| 1440 | + | for p in _retry_ddwtos: | |
| 1441 | + | g = p.get("group", "1") | |
| 1442 | + | if g not in _d_groups: | |
| 1443 | + | _d_groups[g] = [] | |
| 1444 | + | _d_groups[g].append(p) | |
| 1445 | + | _d_prompt = ( | |
| 1446 | + | f"Вопрос {q_num_adp}:\n{_retry_qtext}\n\n" | |
| 1447 | + | f"Предыдущий ответ был НЕВЕРНЫМ.\n" | |
| 1448 | + | ) | |
| 1449 | + | if _fb: | |
| 1450 | + | _d_prompt += f"Обратная связь: {_fb}\n" | |
| 1451 | + | _d_prompt += f"\nВ тексте {len(_retry_ddwtos)} пропусков.\n\n" | |
| 1452 | + | for g_id, places in sorted(_d_groups.items()): | |
| 1453 | + | choices = places[0].get("choices", []) | |
| 1454 | + | choice_texts = [c["text"] for c in sorted(choices, key=lambda c: c["value"])] | |
| 1455 | + | place_nums = [str(p["place"]) for p in places] | |
| 1456 | + | _d_prompt += f"Пропуски {', '.join(place_nums)} — варианты: {', '.join(f'{i+1}. {t}' for i, t in enumerate(choice_texts))}\n" | |
| 1457 | + | _d_prompt += f"\nВерни {len(_retry_ddwtos)} чисел через запятую.\nБез пояснений." | |
| 1458 | + | _target_places = _retry_ddwtos | |
| 1459 | + | ||
| 1460 | + | _d_raw = await _ask_llm(_d_prompt) | |
| 1461 | + | logger.info(f"moodle_solver Q{q_num_adp}: ddwtos retry LLM → {_d_raw!r}") | |
| 1462 | + | _d_nums = re.findall(r'\d+', _d_raw) | |
| 1463 | + | ||
| 1464 | + | for pi, (place, num_str) in enumerate(zip(_target_places, _d_nums)): | |
| 1465 | + | try: | |
| 1466 | + | choice_idx = int(num_str) - 1 | |
| 1467 | + | choices = sorted(place.get("choices", []), key=lambda c: c["value"]) | |
| 1468 | + | if 0 <= choice_idx < len(choices): | |
| 1469 | + | sel = place.get("selector", "") | |
| 1470 | + | if sel: | |
| 1471 | + | await send_fn("browser.setInputValue", { | |
| 1472 | + | "tabId": tab_id, "selector": sel, | |
| 1473 | + | "value": str(choices[choice_idx]["value"]), | |
| 1474 | + | }) | |
| 1475 | + | except Exception as _re: | |
| 1476 | + | logger.warning(f"moodle_solver Q{q_num_adp}: ddwtos retry set failed: {_re}") | |
| 1477 | + | ||
| 1478 | + | # Click Check after ddwtos retry | |
| 1479 | + | _rc_sel = _upd_q.get("checkButtonSelector", check_sel) | |
| 1480 | + | if _rc_sel: | |
| 1481 | + | try: | |
| 1482 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "selector": _rc_sel}) | |
| 1483 | + | logger.info(f"moodle_solver Q{q_num_adp}: clicked Check after ddwtos retry") | |
| 1484 | + | await asyncio.sleep(2.0) | |
| 1485 | + | except Exception as _re: | |
| 1486 | + | logger.warning(f"moodle_solver Q{q_num_adp}: ddwtos retry Check failed: {_re}") | |
| 1487 | + | ||
| 1488 | + | # ── RADIO/CHECKBOX RETRY via LLM ── | |
| 1489 | + | elif _retry_opts: | |
| 1490 | + | _retry_prompt = ( | |
| 1491 | + | f"Вопрос {q_num_adp}:\n{_retry_qtext}\n\n" | |
| 1492 | + | f"Предыдущий ответ был НЕВЕРНЫМ.\n" | |
| 1493 | + | ) | |
| 1494 | + | if _fb: | |
| 1495 | + | _retry_prompt += f"Обратная связь от системы: {_fb}\n" | |
| 1496 | + | if _corr: | |
| 1497 | + | _retry_prompt += f"Статус: {_corr}\n" | |
| 1498 | + | _retry_opt_texts = [o.get("text", "") for o in _retry_opts] | |
| 1499 | + | _is_m = _upd_q.get("multiple", False) | |
| 1500 | + | _lbl = "Чекбоксы (несколько верных)" if _is_m else "Варианты ответа" | |
| 1501 | + | _opts_s = "\n".join( | |
| 1502 | + | f" {i+1}. {t}" for i, t in enumerate(_retry_opt_texts) | |
| 1503 | + | ) | |
| 1504 | + | _retry_prompt += f"\n{_lbl}:\n{_opts_s}\n" | |
| 1505 | + | _retry_prompt += ( | |
| 1506 | + | "\nИсправь ответ. Верни ТОЛЬКО номер(а) через запятую.\n" | |
| 1507 | + | "Без пояснений." | |
| 1508 | + | ) | |
| 1509 | + | ||
| 1510 | + | _retry_raw = await _ask_llm(_retry_prompt) | |
| 1511 | + | logger.info(f"moodle_solver Q{q_num_adp}: retry LLM → {_retry_raw!r}") | |
| 1512 | + | _retry_nums = re.findall(r'\d+', _retry_raw) | |
| 1513 | + | if not _upd_q.get("multiple") and _retry_nums: | |
| 1514 | + | _retry_nums = _retry_nums[:1] | |
| 1515 | + | for _rn in _retry_nums: | |
| 1516 | + | _ri = int(_rn) - 1 | |
| 1517 | + | if 0 <= _ri < len(_retry_opts): | |
| 1518 | + | _r_sel = _retry_opts[_ri].get("selector", "") | |
| 1519 | + | if _r_sel: | |
| 1520 | + | try: | |
| 1521 | + | await send_fn("browser.clickElement", { | |
| 1522 | + | "tabId": tab_id, "selector": _r_sel, | |
| 1523 | + | }) | |
| 1524 | + | except Exception as _re: | |
| 1525 | + | logger.warning(f"moodle_solver Q{q_num_adp}: retry click failed: {_re}") | |
| 1526 | + | # Click Check after radio/checkbox retry | |
| 1527 | + | _rc_sel = _upd_q.get("checkButtonSelector", check_sel) | |
| 1528 | + | if _rc_sel: | |
| 1529 | + | try: | |
| 1530 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "selector": _rc_sel}) | |
| 1531 | + | logger.info(f"moodle_solver Q{q_num_adp}: clicked Check after retry") | |
| 1532 | + | await asyncio.sleep(2.0) | |
| 1533 | + | except Exception as _re: | |
| 1534 | + | logger.warning(f"moodle_solver Q{q_num_adp}: retry Check failed: {_re}") | |
| 1535 | + | ||
| 1536 | + | # ── SELECT RETRY via LLM ── | |
| 1537 | + | elif _retry_selects: | |
| 1538 | + | _retry_prompt = ( | |
| 1539 | + | f"Вопрос {q_num_adp}:\n{_retry_qtext}\n\n" | |
| 1540 | + | f"Предыдущий ответ был НЕВЕРНЫМ.\n" | |
| 1541 | + | ) | |
| 1542 | + | if _fb: | |
| 1543 | + | _retry_prompt += f"Обратная связь от системы: {_fb}\n" | |
| 1544 | + | _retry_prompt += "\nВыпадающие списки:\n" | |
| 1545 | + | for si, sd in enumerate(_retry_selects): | |
| 1546 | + | _s_opts = [o["text"] for o in sd.get("options", []) if o.get("text")] | |
| 1547 | + | _retry_prompt += f" Список {si+1}: {', '.join(_s_opts)}\n" | |
| 1548 | + | _retry_prompt += ( | |
| 1549 | + | "\nИсправь ответ. Верни ТОЛЬКО номер(а) через запятую.\n" | |
| 1550 | + | "Без пояснений." | |
| 1551 | + | ) | |
| 1552 | + | ||
| 1553 | + | _retry_raw = await _ask_llm(_retry_prompt) | |
| 1554 | + | logger.info(f"moodle_solver Q{q_num_adp}: retry LLM → {_retry_raw!r}") | |
| 1555 | + | _retry_nums = re.findall(r'\d+', _retry_raw) | |
| 1556 | + | for si, (sd, ns) in enumerate(zip(_retry_selects, _retry_nums)): | |
| 1557 | + | try: | |
| 1558 | + | _ri = int(ns) - 1 | |
| 1559 | + | _vopts = [o for o in sd.get("options", []) if o.get("value", "0") != "0" and o.get("text")] | |
| 1560 | + | if 0 <= _ri < len(_vopts): | |
| 1561 | + | _ssel = sd.get("selector", "") | |
| 1562 | + | if _ssel: | |
| 1563 | + | await send_fn("browser.selectOption", { | |
| 1564 | + | "tabId": tab_id, "selector": _ssel, | |
| 1565 | + | "value": _vopts[_ri]["value"], | |
| 1566 | + | }) | |
| 1567 | + | except Exception as _re: | |
| 1568 | + | logger.warning(f"moodle_solver Q{q_num_adp}: retry select failed: {_re}") | |
| 1569 | + | # Click Check after select retry | |
| 1570 | + | _rc_sel = _upd_q.get("checkButtonSelector", check_sel) | |
| 1571 | + | if _rc_sel: | |
| 1572 | + | try: | |
| 1573 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "selector": _rc_sel}) | |
| 1574 | + | logger.info(f"moodle_solver Q{q_num_adp}: clicked Check after retry") | |
| 1575 | + | await asyncio.sleep(2.0) | |
| 1576 | + | except Exception as _re: | |
| 1577 | + | logger.warning(f"moodle_solver Q{q_num_adp}: retry Check failed: {_re}") | |
| 1578 | + | ||
| 1579 | + | # After all adaptive questions — navigate to next page / summary | |
| 1580 | + | try: | |
| 1581 | + | _sf_result = await send_fn("browser.submitMoodleForm", {"tabId": tab_id}) | |
| 1582 | + | logger.info(f"moodle_solver: submitMoodleForm after adaptive: {_sf_result}") | |
| 1583 | + | except Exception as _sf_err: | |
| 1584 | + | logger.debug(f"moodle_solver: submitMoodleForm failed ({_sf_err}), falling back to clickNext") | |
| 1585 | + | await _click_next_page() | |
| 1586 | + | ||
| 1587 | + | await asyncio.sleep(2.0) | |
| 1588 | + | continue | |
| 1589 | + | ||
| 1590 | + | else: | |
| 1591 | + | # ── NORMAL MODE: answer all questions, then navigate ── | |
| 1592 | + | for q in mq_list: | |
| 1593 | + | await _answer_moodle_question(q, answered, q_total_str) | |
| 1594 | + | ||
| 1595 | + | # Remember current question IDs before navigation | |
| 1596 | + | _old_q_ids = {q.get("id", "") for q in mq_list if q.get("id")} | |
| 1597 | + | ||
| 1598 | + | # Submit form explicitly (ensures answers are POSTed) | |
| 1599 | + | try: | |
| 1600 | + | _sf_result = await send_fn("browser.submitMoodleForm", {"tabId": tab_id}) | |
| 1601 | + | logger.info(f"moodle_solver: submitMoodleForm result: {_sf_result}") | |
| 1602 | + | except Exception as _sf_err: | |
| 1603 | + | logger.debug(f"moodle_solver: submitMoodleForm failed ({_sf_err}), falling back to clickNext") | |
| 1604 | + | await _click_next_page() | |
| 1605 | + | ||
| 1606 | + | # Wait for page to actually change | |
| 1607 | + | for _wait_i in range(15): # max ~7.5s | |
| 1608 | + | await asyncio.sleep(0.5) | |
| 1609 | + | try: | |
| 1610 | + | _new_mq = await send_fn("browser.getMoodleQuestions", {"tabId": tab_id}) | |
| 1611 | + | _new_list = (_new_mq.get("questions", []) | |
| 1612 | + | if isinstance(_new_mq, dict) else []) | |
| 1613 | + | _new_ids = {q.get("id", "") for q in _new_list if q.get("id")} | |
| 1614 | + | if _new_ids != _old_q_ids: | |
| 1615 | + | break # page changed | |
| 1616 | + | except Exception: | |
| 1617 | + | pass # page may be mid-navigation | |
| 1618 | + | else: | |
| 1619 | + | logger.warning("moodle_solver: page didn't change after 7.5s") | |
| 1620 | + | ||
| 1621 | + | continue | |
| 1622 | + | ||
| 1623 | + | # No questions on page — check if summary page (all questions answered) | |
| 1624 | + | if answered > 0: | |
| 1625 | + | try: | |
| 1626 | + | _summary = await send_fn("browser.getMoodleQuizSummary", {"tabId": tab_id}) | |
| 1627 | + | except Exception: | |
| 1628 | + | _summary = {} | |
| 1629 | + | ||
| 1630 | + | if isinstance(_summary, dict) and _summary.get("isSummaryPage"): | |
| 1631 | + | _s_total = _summary.get("totalQuestions", 0) | |
| 1632 | + | _s_all_ok = _summary.get("allAnswered", False) | |
| 1633 | + | _s_unanswered = _summary.get("unanswered", []) | |
| 1634 | + | _s_submit_sel = _summary.get("submitSelector", "") | |
| 1635 | + | ||
| 1636 | + | logger.info( | |
| 1637 | + | f"moodle_solver: summary page — " | |
| 1638 | + | f"total={_s_total}, allAnswered={_s_all_ok}, " | |
| 1639 | + | f"unanswered={_s_unanswered}" | |
| 1640 | + | ) | |
| 1641 | + | ||
| 1642 | + | _s_invalid = _summary.get("invalid", []) | |
| 1643 | + | _s_problem_nums = list(_s_unanswered) + [q["num"] for q in _s_invalid] | |
| 1644 | + | ||
| 1645 | + | if finish_test: | |
| 1646 | + | if _s_all_ok and not _s_invalid: | |
| 1647 | + | # All answers saved — submit | |
| 1648 | + | _emit({"status": "test_submitting"}) | |
| 1649 | + | _submitted = False | |
| 1650 | + | if _s_submit_sel: | |
| 1651 | + | try: | |
| 1652 | + | await send_fn("browser.clickElement", { | |
| 1653 | + | "tabId": tab_id, "selector": _s_submit_sel | |
| 1654 | + | }) | |
| 1655 | + | logger.info("moodle_solver: clicked submit button") | |
| 1656 | + | _submitted = True | |
| 1657 | + | except Exception: | |
| 1658 | + | pass | |
| 1659 | + | if not _submitted: | |
| 1660 | + | try: | |
| 1661 | + | await send_fn("browser.clickElement", { | |
| 1662 | + | "tabId": tab_id, | |
| 1663 | + | "text": "Отправить всё и завершить тест" | |
| 1664 | + | }) | |
| 1665 | + | _submitted = True | |
| 1666 | + | except Exception as e: | |
| 1667 | + | logger.warning(f"moodle_solver: submit click failed: {e}") | |
| 1668 | + | if _submitted: | |
| 1669 | + | # Wait for Moodle confirmation modal to appear and confirm | |
| 1670 | + | _confirmed = False | |
| 1671 | + | for _modal_wait in range(10): # up to 5s | |
| 1672 | + | await asyncio.sleep(0.5) | |
| 1673 | + | # Try CSS selector first (Moodle modal save button) | |
| 1674 | + | for _sel in [ | |
| 1675 | + | ".modal.show [data-action='save']", | |
| 1676 | + | ".moodle-dialogue [data-action='save']", | |
| 1677 | + | ".confirmation-dialogue [data-action='save']", | |
| 1678 | + | "[data-action='save'].btn-primary", | |
| 1679 | + | ]: | |
| 1680 | + | try: | |
| 1681 | + | await send_fn("browser.clickElement", { | |
| 1682 | + | "tabId": tab_id, "selector": _sel | |
| 1683 | + | }) | |
| 1684 | + | logger.info(f"moodle_solver: confirmed via selector {_sel}") | |
| 1685 | + | _confirmed = True | |
| 1686 | + | break | |
| 1687 | + | except Exception: | |
| 1688 | + | pass | |
| 1689 | + | if _confirmed: | |
| 1690 | + | break | |
| 1691 | + | # Fallback: try text match | |
| 1692 | + | for _confirm_text in [ | |
| 1693 | + | "Отправить всё и завершить тест", | |
| 1694 | + | "Submit all and finish", | |
| 1695 | + | ]: | |
| 1696 | + | try: | |
| 1697 | + | await send_fn("browser.clickElement", { | |
| 1698 | + | "tabId": tab_id, "text": _confirm_text | |
| 1699 | + | }) | |
| 1700 | + | logger.info(f"moodle_solver: confirmed via text '{_confirm_text}'") | |
| 1701 | + | _confirmed = True | |
| 1702 | + | break | |
| 1703 | + | except Exception: | |
| 1704 | + | pass | |
| 1705 | + | if _confirmed: | |
| 1706 | + | break | |
| 1707 | + | ||
| 1708 | + | if not _confirmed: | |
| 1709 | + | logger.warning("moodle_solver: could not confirm submission modal") | |
| 1710 | + | ||
| 1711 | + | # Wait for page to load after submission | |
| 1712 | + | await asyncio.sleep(2.0) | |
| 1713 | + | _test_submitted = _confirmed | |
| 1714 | + | ||
| 1715 | + | # ── Handle review page → results page flow ── | |
| 1716 | + | _moodle_results_data = None | |
| 1717 | + | try: | |
| 1718 | + | _quiz_result = await send_fn("browser.getMoodleQuizResults", {"tabId": tab_id}) | |
| 1719 | + | except Exception: | |
| 1720 | + | _quiz_result = {} | |
| 1721 | + | ||
| 1722 | + | if _quiz_result.get("isQuizResultPage") and _quiz_result.get("pageType") == "review": | |
| 1723 | + | # We're on the review page — extract summary, then click "Закончить обзор" | |
| 1724 | + | _review_summary = _quiz_result.get("reviewSummary", {}) | |
| 1725 | + | logger.info(f"moodle_solver: review page detected, summary={_review_summary}") | |
| 1726 | + | _emit({ | |
| 1727 | + | "status": "test_review", | |
| 1728 | + | "message": "Обзор результатов...", | |
| 1729 | + | }) | |
| 1730 | + | ||
| 1731 | + | _finish_href = _quiz_result.get("finishReviewHref", "") | |
| 1732 | + | _navigated_to_results = False | |
| 1733 | + | ||
| 1734 | + | if _finish_href: | |
| 1735 | + | try: | |
| 1736 | + | await send_fn("browser.navigateTab", {"tabId": tab_id, "url": _finish_href}) | |
| 1737 | + | _navigated_to_results = True | |
| 1738 | + | except Exception: | |
| 1739 | + | pass | |
| 1740 | + | ||
| 1741 | + | if not _navigated_to_results: | |
| 1742 | + | for _finish_text in ["Закончить обзор", "Finish review"]: | |
| 1743 | + | try: | |
| 1744 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "text": _finish_text}) | |
| 1745 | + | _navigated_to_results = True | |
| 1746 | + | break | |
| 1747 | + | except Exception: | |
| 1748 | + | pass | |
| 1749 | + | ||
| 1750 | + | if _navigated_to_results: | |
| 1751 | + | await asyncio.sleep(2.0) | |
| 1752 | + | try: | |
| 1753 | + | _quiz_result = await send_fn("browser.getMoodleQuizResults", {"tabId": tab_id}) | |
| 1754 | + | except Exception: | |
| 1755 | + | _quiz_result = {} | |
| 1756 | + | ||
| 1757 | + | if _quiz_result.get("isQuizResultPage") and _quiz_result.get("pageType") == "results": | |
| 1758 | + | _moodle_results_data = _quiz_result | |
| 1759 | + | logger.info(f"moodle_solver: results page — attempts={_quiz_result.get('attempts')}, feedback={_quiz_result.get('feedback')}") | |
| 1760 | + | ||
| 1761 | + | # Read page text as fallback for regex parsing | |
| 1762 | + | _results_text, _ = await _read_page() | |
| 1763 | + | break # Exit loop — submitted | |
| 1764 | + | ||
| 1765 | + | elif _s_problem_nums and _summary_fix_attempts < _MAX_SUMMARY_FIX_ATTEMPTS: | |
| 1766 | + | # ── Go back and fix invalid/unanswered questions ── | |
| 1767 | + | _summary_fix_attempts += 1 | |
| 1768 | + | _problem_str = ", ".join(str(n) for n in _s_problem_nums) | |
| 1769 | + | logger.info( | |
| 1770 | + | f"moodle_solver: summary fix attempt {_summary_fix_attempts}/{_MAX_SUMMARY_FIX_ATTEMPTS} " | |
| 1771 | + | f"— problems: {_problem_str}" | |
| 1772 | + | ) | |
| 1773 | + | _emit({ | |
| 1774 | + | "status": "test_fixing", | |
| 1775 | + | "message": f"Исправляю вопросы: {_problem_str} (попытка {_summary_fix_attempts})", | |
| 1776 | + | }) | |
| 1777 | + | ||
| 1778 | + | # Navigate back: prefer direct link to problem question page | |
| 1779 | + | _went_back = False | |
| 1780 | + | # Try using href from the first invalid question (navigates to correct page) | |
| 1781 | + | _first_invalid_href = "" | |
| 1782 | + | for _inv in _s_invalid: | |
| 1783 | + | if _inv.get("href"): | |
| 1784 | + | _first_invalid_href = _inv["href"] | |
| 1785 | + | break | |
| 1786 | + | if _first_invalid_href: | |
| 1787 | + | try: | |
| 1788 | + | await send_fn("browser.navigateTab", {"tabId": tab_id, "url": _first_invalid_href}) | |
| 1789 | + | _went_back = True | |
| 1790 | + | except Exception: | |
| 1791 | + | pass | |
| 1792 | + | # Fallback: returnHref | |
| 1793 | + | if not _went_back: | |
| 1794 | + | _return_href = _summary.get("returnHref", "") | |
| 1795 | + | if _return_href: | |
| 1796 | + | try: | |
| 1797 | + | await send_fn("browser.navigateTab", {"tabId": tab_id, "url": _return_href}) | |
| 1798 | + | _went_back = True | |
| 1799 | + | except Exception: | |
| 1800 | + | pass | |
| 1801 | + | if not _went_back: | |
| 1802 | + | for _back_text in ["Вернуться к попытке", "Return to attempt"]: | |
| 1803 | + | try: | |
| 1804 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "text": _back_text}) | |
| 1805 | + | _went_back = True | |
| 1806 | + | break | |
| 1807 | + | except Exception: | |
| 1808 | + | pass | |
| 1809 | + | if not _went_back: | |
| 1810 | + | _return_sel = _summary.get("returnSelector", "") | |
| 1811 | + | if _return_sel: | |
| 1812 | + | try: | |
| 1813 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "selector": _return_sel}) | |
| 1814 | + | _went_back = True | |
| 1815 | + | except Exception: | |
| 1816 | + | pass | |
| 1817 | + | ||
| 1818 | + | if not _went_back: | |
| 1819 | + | logger.warning("moodle_solver: could not navigate back from summary") | |
| 1820 | + | _submit_problem = f"Не удалось вернуться к попытке для исправления вопросов: {_problem_str}" | |
| 1821 | + | break | |
| 1822 | + | ||
| 1823 | + | await asyncio.sleep(1.5) | |
| 1824 | + | # Now we need to find and fix the problem questions | |
| 1825 | + | # getMoodleQuestions on current page, fix only problem ones | |
| 1826 | + | try: | |
| 1827 | + | _fix_data = await send_fn("browser.getMoodleQuestions", {"tabId": tab_id}) | |
| 1828 | + | except Exception: | |
| 1829 | + | _fix_data = {} | |
| 1830 | + | _fix_questions = _fix_data.get("questions", []) | |
| 1831 | + | _fixed_any = False | |
| 1832 | + | for _fq in _fix_questions: | |
| 1833 | + | _fq_num = str(_fq.get("qno", "")) | |
| 1834 | + | if _fq_num not in [str(n) for n in _s_problem_nums]: | |
| 1835 | + | continue | |
| 1836 | + | _val_err = _fq.get("validationError", "") | |
| 1837 | + | logger.info(f"moodle_solver: fixing Q{_fq_num} on return" | |
| 1838 | + | f"{' (validation: '+_val_err+')' if _val_err else ''}") | |
| 1839 | + | # If validation error mentions "число" — clear text input value first | |
| 1840 | + | if _val_err and "число" in _val_err.lower(): | |
| 1841 | + | # Clear any existing bad value in text inputs | |
| 1842 | + | for _ti in _fq.get("textInputs", []): | |
| 1843 | + | if _ti.get("value"): | |
| 1844 | + | _ti["value"] = "" # reset so solver re-types | |
| 1845 | + | # Re-answer this question | |
| 1846 | + | await _answer_moodle_question(_fq, 0, str(_s_total)) | |
| 1847 | + | _fixed_any = True | |
| 1848 | + | ||
| 1849 | + | if not _fixed_any: | |
| 1850 | + | logger.warning("moodle_solver: problem questions not found on this page") | |
| 1851 | + | # Try navigating to next page if multi-page quiz | |
| 1852 | + | break | |
| 1853 | + | ||
| 1854 | + | # Submit the page to save answers | |
| 1855 | + | await asyncio.sleep(0.5) | |
| 1856 | + | try: | |
| 1857 | + | await send_fn("browser.submitMoodleForm", {"tabId": tab_id}) | |
| 1858 | + | except Exception: | |
| 1859 | + | try: | |
| 1860 | + | await send_fn("browser.clickElement", { | |
| 1861 | + | "tabId": tab_id, "text": "Закончить попытку" | |
| 1862 | + | }) | |
| 1863 | + | except Exception: | |
| 1864 | + | pass | |
| 1865 | + | await asyncio.sleep(1.5) | |
| 1866 | + | # Loop will continue — next iteration should land on summary again | |
| 1867 | + | continue | |
| 1868 | + | ||
| 1869 | + | else: | |
| 1870 | + | # Max fix attempts reached or no problems detected | |
| 1871 | + | _problem_str = ", ".join(str(n) for n in _s_problem_nums) | |
| 1872 | + | _submit_problem = f"Не все ответы сохранены (вопросы: {_problem_str})" | |
| 1873 | + | break | |
| 1874 | + | break # Exit loop — we're on summary page (no finish_test) | |
| 1875 | + | ||
| 1876 | + | # ── GENERIC PATH (non-Moodle) ──────────────────────────────── | |
| 1877 | + | q_match = re.search( | |
| 1878 | + | r'(?:вопрос|question|задание)\s*(\d+)\s*(?:из|of|/)?\s*(\d+)?', | |
| 1879 | + | text_lower | |
| 1880 | + | ) | |
| 1881 | + | if not q_match: | |
| 1882 | + | q_match = re.search(r'(\d+)\s*(?:/|из|of)\s*(\d+)', text_lower) | |
| 1883 | + | q_num = q_match.group(1) if q_match else str(answered + 1) | |
| 1884 | + | q_total = ( | |
| 1885 | + | q_match.group(2) | |
| 1886 | + | if q_match and q_match.lastindex and q_match.lastindex >= 2 | |
| 1887 | + | else None | |
| 1888 | + | ) or "?" | |
| 1889 | + | ||
| 1890 | + | _sig = _detect_signature(text, elems) | |
| 1891 | + | interaction = _sig["interaction"] | |
| 1892 | + | is_multiple = _sig.get("multiple", False) | |
| 1893 | + | ||
| 1894 | + | # Classify interactive elements | |
| 1895 | + | radio_buttons: List[dict] = [] | |
| 1896 | + | checkboxes: List[dict] = [] | |
| 1897 | + | text_inputs: List[dict] = [] | |
| 1898 | + | option_elems: List[dict] = [] | |
| 1899 | + | next_btn: Optional[dict] = None | |
| 1900 | + | ||
| 1901 | + | for el in elems: | |
| 1902 | + | el_tag = (el.get("tag") or "").lower() | |
| 1903 | + | el_type = (el.get("type") or "").lower() | |
| 1904 | + | el_text_raw = (el.get("text") or "").strip() | |
| 1905 | + | el_text_lo = el_text_raw.lower() | |
| 1906 | + | el_id = (el.get("id") or "").lower() | |
| 1907 | + | ||
| 1908 | + | if any(kw in el_text_lo for kw in [ | |
| 1909 | + | "далее", "next", "следующ", "завершить", "finish", "submit", | |
| 1910 | + | "отправить", "проверить", | |
| 1911 | + | ]): | |
| 1912 | + | if not next_btn: | |
| 1913 | + | next_btn = el | |
| 1914 | + | continue | |
| 1915 | + | if any(kw in el_text_lo for kw in ["назад", "prev", "back", "предыдущ"]): | |
| 1916 | + | continue | |
| 1917 | + | if el_type == "radio": | |
| 1918 | + | radio_buttons.append(el) | |
| 1919 | + | elif el_type == "checkbox": | |
| 1920 | + | checkboxes.append(el) | |
| 1921 | + | elif el_tag in ("input", "textarea") and el_type in ("text", "number", "textarea", ""): | |
| 1922 | + | text_inputs.append(el) | |
| 1923 | + | elif el_tag in ("div", "span", "label", "li", "a", "button", "p"): | |
| 1924 | + | if el_text_raw and len(el_text_raw) < 500: | |
| 1925 | + | option_elems.append(el) | |
| 1926 | + | ||
| 1927 | + | primary_elems: List[dict] = [] | |
| 1928 | + | all_options: List[str] = [] | |
| 1929 | + | if radio_buttons: | |
| 1930 | + | primary_elems = radio_buttons | |
| 1931 | + | all_options = [el.get("text", "").strip() for el in radio_buttons] | |
| 1932 | + | is_multiple = False | |
| 1933 | + | elif checkboxes: | |
| 1934 | + | primary_elems = checkboxes | |
| 1935 | + | all_options = [el.get("text", "").strip() for el in checkboxes] | |
| 1936 | + | is_multiple = True | |
| 1937 | + | elif option_elems: | |
| 1938 | + | primary_elems = option_elems | |
| 1939 | + | all_options = [el.get("text", "").strip() for el in option_elems] | |
| 1940 | + | ||
| 1941 | + | q_short = _extract_question_text(text, all_options, None)[:80] | |
| 1942 | + | ||
| 1943 | + | # Build LLM prompt | |
| 1944 | + | prompt = f"Вопрос {q_num} из {q_total}:\n{q_short}\n" | |
| 1945 | + | if all_options: | |
| 1946 | + | label = "Чекбоксы (несколько верных)" if is_multiple else "Варианты ответа" | |
| 1947 | + | opts_str = "\n".join(f" {i+1}. {t}" for i, t in enumerate(all_options)) | |
| 1948 | + | prompt += f"\n{label}:\n{opts_str}\n" | |
| 1949 | + | prompt += ( | |
| 1950 | + | "\nИнструкция:\n" | |
| 1951 | + | "- Варианты → верни ТОЛЬКО номер(а) через запятую\n" | |
| 1952 | + | "- Текстовый ответ → верни ТЕКСТ: ответ\n" | |
| 1953 | + | "- Без пояснений." | |
| 1954 | + | ) | |
| 1955 | + | ||
| 1956 | + | delay = _human_delay_sec(q_short, all_options or None) | |
| 1957 | + | _total_simulated_sec += delay | |
| 1958 | + | await asyncio.sleep(delay) | |
| 1959 | + | ||
| 1960 | + | answer_raw = await _ask_llm(prompt) | |
| 1961 | + | chosen_answer = "" | |
| 1962 | + | ||
| 1963 | + | if all_options: | |
| 1964 | + | nums = re.findall(r'\d+', answer_raw) | |
| 1965 | + | if not is_multiple and nums: | |
| 1966 | + | nums = nums[:1] | |
| 1967 | + | for num_str in nums: | |
| 1968 | + | idx = int(num_str) - 1 | |
| 1969 | + | if 0 <= idx < len(primary_elems): | |
| 1970 | + | ok = await _click_option( | |
| 1971 | + | all_options[idx], primary_elems[idx], interaction, idx | |
| 1972 | + | ) | |
| 1973 | + | if ok: | |
| 1974 | + | chosen_answer += all_options[idx] + "; " | |
| 1975 | + | ||
| 1976 | + | answered += 1 | |
| 1977 | + | _final = (chosen_answer.rstrip("; ") if chosen_answer else answer_raw).strip() | |
| 1978 | + | _qa_records.append({ | |
| 1979 | + | "num": q_num, "total": q_total, | |
| 1980 | + | "text": q_short, "answer": _final, "delay_sec": round(delay, 1), | |
| 1981 | + | }) | |
| 1982 | + | ||
| 1983 | + | await _click_next_page() | |
| 1984 | + | await asyncio.sleep(0.5) | |
| 1985 | + | ||
| 1986 | + | # ── Parse score from results page ───────────────────────────────────── | |
| 1987 | + | _correct_total = 0 | |
| 1988 | + | _incorrect_total = 0 | |
| 1989 | + | _score_pct = "" | |
| 1990 | + | _moodle_points: Optional[str] = None # e.g. "18,80 / 20,00" | |
| 1991 | + | _moodle_grade: Optional[str] = None # e.g. "94,00 / 100,00" | |
| 1992 | + | _moodle_feedback: str = "" | |
| 1993 | + | _moodle_attempts_table: List[dict] = [] | |
| 1994 | + | ||
| 1995 | + | # ── Try to detect and navigate review → results page ──────────── | |
| 1996 | + | if not _moodle_results_data: | |
| 1997 | + | try: | |
| 1998 | + | _quiz_result = await send_fn("browser.getMoodleQuizResults", {"tabId": tab_id}) | |
| 1999 | + | except Exception: | |
| 2000 | + | _quiz_result = {} | |
| 2001 | + | ||
| 2002 | + | if _quiz_result.get("isQuizResultPage") and _quiz_result.get("pageType") == "review": | |
| 2003 | + | _review_summary = _quiz_result.get("reviewSummary", {}) | |
| 2004 | + | logger.info(f"moodle_solver: on review page after loop, summary={_review_summary}") | |
| 2005 | + | _emit({"status": "test_review", "message": "Обзор результатов..."}) | |
| 2006 | + | ||
| 2007 | + | _finish_href = _quiz_result.get("finishReviewHref", "") | |
| 2008 | + | _navigated = False | |
| 2009 | + | if _finish_href: | |
| 2010 | + | try: | |
| 2011 | + | await send_fn("browser.navigateTab", {"tabId": tab_id, "url": _finish_href}) | |
| 2012 | + | _navigated = True | |
| 2013 | + | except Exception: | |
| 2014 | + | pass | |
| 2015 | + | if not _navigated: | |
| 2016 | + | for _ft in ["Закончить обзор", "Finish review"]: | |
| 2017 | + | try: | |
| 2018 | + | await send_fn("browser.clickElement", {"tabId": tab_id, "text": _ft}) | |
| 2019 | + | _navigated = True | |
| 2020 | + | break | |
| 2021 | + | except Exception: | |
| 2022 | + | pass | |
| 2023 | + | if _navigated: | |
| 2024 | + | await asyncio.sleep(2.0) | |
| 2025 | + | try: | |
| 2026 | + | _quiz_result = await send_fn("browser.getMoodleQuizResults", {"tabId": tab_id}) | |
| 2027 | + | except Exception: | |
| 2028 | + | _quiz_result = {} | |
| 2029 | + | # Use whatever we got (review or results) | |
| 2030 | + | if _quiz_result.get("isQuizResultPage"): | |
| 2031 | + | _moodle_results_data = _quiz_result | |
| 2032 | + | _results_text, _ = await _read_page() | |
| 2033 | + | logger.info(f"moodle_solver: got results data from {_quiz_result.get('pageType')} page") | |
| 2034 | + | ||
| 2035 | + | elif _quiz_result.get("isQuizResultPage") and _quiz_result.get("pageType") == "results": | |
| 2036 | + | _moodle_results_data = _quiz_result | |
| 2037 | + | logger.info("moodle_solver: already on results page") | |
| 2038 | + | ||
| 2039 | + | # ── Structured results from extension (higher priority) ─────────── | |
| 2040 | + | if _moodle_results_data: | |
| 2041 | + | # Results page — attempts table | |
| 2042 | + | _moodle_attempts_table = _moodle_results_data.get("attempts", []) | |
| 2043 | + | _moodle_feedback = _moodle_results_data.get("feedback", "") | |
| 2044 | + | ||
| 2045 | + | # Extract points/grade from attempts table (last attempt row) | |
| 2046 | + | if _moodle_attempts_table: | |
| 2047 | + | _last_attempt = _moodle_attempts_table[-1] | |
| 2048 | + | for _col_key, _col_val in _last_attempt.items(): | |
| 2049 | + | _ck = _col_key.lower() | |
| 2050 | + | _slash_parts = _col_key.split("/") | |
| 2051 | + | if len(_slash_parts) != 2: | |
| 2052 | + | continue | |
| 2053 | + | _max_val = _slash_parts[1].strip().replace(",", ".").strip() | |
| 2054 | + | _got_val = _col_val.strip().replace(",", ".") | |
| 2055 | + | _got_match = re.search(r'[\d]+[.]?[\d]*', _got_val) | |
| 2056 | + | _max_match = re.search(r'[\d]+[.]?[\d]*', _max_val) | |
| 2057 | + | if not _got_match or not _max_match: | |
| 2058 | + | continue | |
| 2059 | + | _got_num = _got_match.group(0) | |
| 2060 | + | _max_num = _max_match.group(0) | |
| 2061 | + | # "Баллы / 10,00" → points; "Оценка / 5,00" → grade | |
| 2062 | + | if "балл" in _ck or "marks" in _ck or "mark" in _ck: | |
| 2063 | + | _moodle_points = f"{_got_num} / {_max_num}" | |
| 2064 | + | try: | |
| 2065 | + | _correct_total = round(float(_got_num)) | |
| 2066 | + | _incorrect_total = round(float(_max_num)) - _correct_total | |
| 2067 | + | except ValueError: | |
| 2068 | + | pass | |
| 2069 | + | elif "оценка" in _ck or "grade" in _ck: | |
| 2070 | + | _moodle_grade = f"{_got_num} / {_max_num}" | |
| 2071 | + | ||
| 2072 | + | # Review summary (fallback if no attempts table) | |
| 2073 | + | _review_summary = _moodle_results_data.get("reviewSummary", {}) | |
| 2074 | + | if _review_summary and not _moodle_points: | |
| 2075 | + | for _rk, _rv in _review_summary.items(): | |
| 2076 | + | _rkl = _rk.lower() | |
| 2077 | + | if "балл" in _rkl: | |
| 2078 | + | _pts_m = re.search(r'([\d]+[,.][\d]+)\s*/\s*([\d]+[,.][\d]+)', _rv) | |
| 2079 | + | if _pts_m: | |
| 2080 | + | _moodle_points = f"{_pts_m.group(1).replace(',','.')} / {_pts_m.group(2).replace(',','.')}" | |
| 2081 | + | elif "оценка" in _rkl: | |
| 2082 | + | _grd_m = re.search(r'([\d]+[,.][\d]+)\s*(?:из|/)\s*([\d]+[,.][\d]+)', _rv) | |
| 2083 | + | if _grd_m: | |
| 2084 | + | _moodle_grade = f"{_grd_m.group(1).replace(',','.')} / {_grd_m.group(2).replace(',','.')}" | |
| 2085 | + | _pct_m = re.search(r'\((\d+)%?\)', _rv) | |
| 2086 | + | if _pct_m: | |
| 2087 | + | _score_pct = _pct_m.group(1) | |
| 2088 | + | elif "отзыв" in _rkl or "feedback" in _rkl: | |
| 2089 | + | _moodle_feedback = _rv | |
| 2090 | + | ||
| 2091 | + | if _results_text: | |
| 2092 | + | _rt = _results_text | |
| 2093 | + | ||
| 2094 | + | # Moodle results page: "Баллы / 20,00" header + value "18,80" | |
| 2095 | + | # Text from _read_page flattens the table, so look for patterns like: | |
| 2096 | + | # "Баллы / 20,00 ... 18,80" or "Marks ... 18.80 / 20.00" | |
| 2097 | + | # Also: "Средняя оценка: 94,00 / 100,00." | |
| 2098 | + | ||
| 2099 | + | # Pattern: "Баллы / MAX" ... number (with comma or dot as decimal) | |
| 2100 | + | _p_marks = re.search( | |
| 2101 | + | r'[Бб]алл\w*\s*/\s*([\d]+[,.][\d]+|[\d]+)', | |
| 2102 | + | _rt | |
| 2103 | + | ) | |
| 2104 | + | _p_grade = re.search( | |
| 2105 | + | r'[Оо]ценка\s*/\s*([\d]+[,.][\d]+|[\d]+)', | |
| 2106 | + | _rt | |
| 2107 | + | ) | |
| 2108 | + | ||
| 2109 | + | if _p_marks and not _moodle_points: | |
| 2110 | + | _marks_max = _p_marks.group(1).replace(',', '.') | |
| 2111 | + | _after_marks = _rt[_p_marks.end():] | |
| 2112 | + | _score_nums = re.findall(r'([\d]+[,.][\d]+)', _after_marks[:200]) | |
| 2113 | + | for _sn in _score_nums: | |
| 2114 | + | _marks_got = _sn.replace(',', '.') | |
| 2115 | + | try: | |
| 2116 | + | if float(_marks_got) <= float(_marks_max): | |
| 2117 | + | _moodle_points = f"{_marks_got} / {_marks_max}" | |
| 2118 | + | _correct_total = round(float(_marks_got)) | |
| 2119 | + | _incorrect_total = round(float(_marks_max)) - _correct_total | |
| 2120 | + | break | |
| 2121 | + | except ValueError: | |
| 2122 | + | pass | |
| 2123 | + | ||
| 2124 | + | if _p_grade and not _moodle_grade: | |
| 2125 | + | _grade_max = _p_grade.group(1).replace(',', '.') | |
| 2126 | + | _after_grade = _rt[_p_grade.end():] | |
| 2127 | + | _grade_nums = re.findall(r'([\d]+[,.][\d]+)', _after_grade[:200]) | |
| 2128 | + | for _gn in _grade_nums: | |
| 2129 | + | _grade_got = _gn.replace(',', '.') | |
| 2130 | + | try: | |
| 2131 | + | if float(_grade_got) <= float(_grade_max): | |
| 2132 | + | _moodle_grade = f"{_grade_got} / {_grade_max}" | |
| 2133 | + | _score_pct = str(round(float(_grade_got))) | |
| 2134 | + | break | |
| 2135 | + | except ValueError: | |
| 2136 | + | pass | |
| 2137 | + | ||
| 2138 | + | # Fallback: "Средняя оценка: X / Y" | |
| 2139 | + | if not _moodle_grade: | |
| 2140 | + | _avg_m = re.search( | |
| 2141 | + | r'[Сс]редняя\s+оценка[:\s]*([\d]+[,.][\d]+)\s*/\s*([\d]+[,.][\d]+)', | |
| 2142 | + | _rt | |
| 2143 | + | ) | |
| 2144 | + | if _avg_m: | |
| 2145 | + | _g = _avg_m.group(1).replace(',', '.') | |
| 2146 | + | _gm = _avg_m.group(2).replace(',', '.') | |
| 2147 | + | _moodle_grade = f"{_g} / {_gm}" | |
| 2148 | + | if not _score_pct: | |
| 2149 | + | try: | |
| 2150 | + | _score_pct = str(round(float(_g))) | |
| 2151 | + | except ValueError: | |
| 2152 | + | pass | |
| 2153 | + | ||
| 2154 | + | # Fallback: generic patterns | |
| 2155 | + | if not _moodle_points and not _moodle_grade: | |
| 2156 | + | _moodle_marks_fb = re.search( | |
| 2157 | + | r'(?:marks|оценка|grade)\s*[:\s]*(\d+\.?\d*)\s*/\s*(\d+\.?\d*)', | |
| 2158 | + | _rt, re.IGNORECASE | |
| 2159 | + | ) | |
| 2160 | + | if _moodle_marks_fb: | |
| 2161 | + | try: | |
| 2162 | + | _correct_total = round(float(_moodle_marks_fb.group(1))) | |
| 2163 | + | _incorrect_total = round(float(_moodle_marks_fb.group(2))) - _correct_total | |
| 2164 | + | except ValueError: | |
| 2165 | + | pass | |
| 2166 | + | else: | |
| 2167 | + | _sc = re.search(r'правильно\s+на\s+(\d+)\s+из\s+(\d+)', _rt, re.IGNORECASE) | |
| 2168 | + | if _sc: | |
| 2169 | + | _correct_total = int(_sc.group(1)) | |
| 2170 | + | _incorrect_total = int(_sc.group(2)) - _correct_total | |
| 2171 | + | ||
| 2172 | + | if not _score_pct: | |
| 2173 | + | _pct = re.search(r'(\d+)\s*%', _rt) | |
| 2174 | + | if _pct: | |
| 2175 | + | _score_pct = _pct.group(1) | |
| 2176 | + | ||
| 2177 | + | # ── Build markdown report ────────────────────────────────────────────── | |
| 2178 | + | _emit({"status": "test_complete", "answered": answered}) | |
| 2179 | + | _avg = _total_simulated_sec / answered if answered else 0.0 | |
| 2180 | + | _m, _s = divmod(int(_total_simulated_sec), 60) | |
| 2181 | + | _time_str = f"{_m} мин {_s} с" if _m else f"{_s} с" | |
| 2182 | + | ||
| 2183 | + | # Header with submission status | |
| 2184 | + | if _test_submitted: | |
| 2185 | + | _header = f"## Тест завершён — {answered} вопросов" | |
| 2186 | + | elif _submit_problem: | |
| 2187 | + | _header = f"## Тест пройден (не отправлен) — {answered} вопросов" | |
| 2188 | + | elif finish_test: | |
| 2189 | + | _header = f"## Тест пройден (не удалось отправить) — {answered} вопросов" | |
| 2190 | + | else: | |
| 2191 | + | _header = f"## Тест пройден — {answered} вопросов" | |
| 2192 | + | ||
| 2193 | + | lines_out: List[str] = [ | |
| 2194 | + | _header, | |
| 2195 | + | f"Время: {_time_str} (среднее {_avg:.1f} с/вопрос)", | |
| 2196 | + | "", | |
| 2197 | + | ] | |
| 2198 | + | ||
| 2199 | + | # Submission status line | |
| 2200 | + | if _test_submitted: | |
| 2201 | + | lines_out.append("Тест отправлен и завершён.") | |
| 2202 | + | lines_out.append("") | |
| 2203 | + | elif _submit_problem: | |
| 2204 | + | lines_out.append(f"⚠ {_submit_problem}") | |
| 2205 | + | lines_out.append("") | |
| 2206 | + | elif finish_test: | |
| 2207 | + | lines_out.append("⚠ Не удалось найти страницу подтверждения для отправки теста.") | |
| 2208 | + | lines_out.append("") | |
| 2209 | + | ||
| 2210 | + | # Per-question answers | |
| 2211 | + | for rec in _qa_records: | |
| 2212 | + | lines_out.append(f"**Вопрос {rec['num']}.** {rec['text']}") | |
| 2213 | + | lines_out.append(f"- {rec['answer']}") | |
| 2214 | + | lines_out.append("") | |
| 2215 | + | ||
| 2216 | + | if _moodle_points or _moodle_grade: | |
| 2217 | + | # Moodle-style results table | |
| 2218 | + | lines_out.append("---") | |
| 2219 | + | lines_out.append("### Результаты") | |
| 2220 | + | lines_out.append("") | |
| 2221 | + | lines_out.append("| Показатель | Получено | Максимум |") | |
| 2222 | + | lines_out.append("|---|---|---|") | |
| 2223 | + | if _moodle_points: | |
| 2224 | + | _pts = _moodle_points.split(" / ") | |
| 2225 | + | lines_out.append(f"| Баллы | {_pts[0]} | {_pts[1]} |") | |
| 2226 | + | if _moodle_grade: | |
| 2227 | + | _grd = _moodle_grade.split(" / ") | |
| 2228 | + | lines_out.append(f"| Оценка | {_grd[0]} | {_grd[1]} |") | |
| 2229 | + | elif _correct_total or _incorrect_total or _score_pct: | |
| 2230 | + | lines_out.append("---") | |
| 2231 | + | lines_out.append("### Итог") | |
| 2232 | + | if _correct_total or _incorrect_total: | |
| 2233 | + | lines_out.append(f"- Правильно: **{_correct_total}** из **{_correct_total + _incorrect_total}**") | |
| 2234 | + | if _score_pct: | |
| 2235 | + | lines_out.append(f"- Результат: **{_score_pct}%**") | |
| 2236 | + | ||
| 2237 | + | # Feedback line | |
| 2238 | + | if _moodle_feedback: | |
| 2239 | + | lines_out.append("") | |
| 2240 | + | lines_out.append(f"**Отзыв:** {_moodle_feedback}") | |
| 2241 | + | ||
| 2242 | + | # End MOODLE_SOLVE span | |
| 2243 | + | await _end_span(_solve_span, success=True, metadata_updates={ | |
| 2244 | + | "questions_total": answered, | |
| 2245 | + | "score": _score_pct or "", | |
| 2246 | + | "grade": _moodle_grade or "", | |
| 2247 | + | "test_submitted": _test_submitted, | |
| 2248 | + | }) | |
| 2249 | + | ||
| 2250 | + | return "\n".join(lines_out) | |
Newer
Older