Last active 1773570871

en2zmax's Avatar en2zmax revised this gist 1773570871. 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