Last active 1773570871

Revision f7bcba25864e3e0e01940ceb88cf7a7dd1c36734

moodle.py Raw
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""
4moodle.py — автономное прохождение Moodle-тестов.
5
6Поддержка: Moodle 3.x / 4.x quiz module.
7Generic-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
32import asyncio
33import logging
34import random
35import re
36from typing import Any, Callable, Dict, List, Optional
37
38logger = logging.getLogger(__name__)
39
40MAX_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# ---------------------------------------------------------------------------
56MOODLE_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)
124MOODLE_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)
133RESULT_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
154def _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
205def _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
210def _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
284async 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)
2251