""" md_blocks.py ------------ 段落 / 清單 / 特殊區塊(card、quiz、flow、solution、ggb、icon)解析, 以及文件級 parse_document 與 TOC 指派。 依賴:md_color, md_tex, md_attrs, md_runs 公開 API: parse_document(text) → List[block] 主要入口 parse_content_block(text) → List[block] parse_solution_block(src) → block parse_card_block(src) → block parse_interactive_block(src) → block parse_flow_block(src) → block parse_quizzes(md) → List[block] parse_geogebra_marker(s) → block parse_icon_marker(s) → block parse_pyfunc_marker(s) → block parse_list_items(list_block) → List[item] parse_paragraph_runs_color_aware → List[run] parse_line_color_aware → List[run] assign_ids_and_build_toc(nodes) → List[toc_entry] split_blocks_into_sections(blocks)→ List[section] build_ai_prompt_for_example(...) → str build_ai_prompt_for_pos(...) → str build_ai_prompt_for_solution_pos(...)→ str """ from __future__ import annotations import re import hashlib import unicodedata import json from .md_color import attach_theme_colors, _norm_hex from .md_tex import _ensure_inline_dollars, _ensure_block_dollars from .md_attrs import _attrs_to_dict, _parse_attrs, _slug_ascii from .md_runs import ( build_inline_runs, normalize_runs, _has_newline_token, _plain_text_from_runs, _append_hard_break, _split_text_and_any_display_math, _split_text_links_images, _split_text_and_ggb, _split_text_and_icon, ) # --------------------------------------------------------------------------- # 模組級正規表達式(只保留 md_blocks 內部用的) # --------------------------------------------------------------------------- _COLOR_TAG = re.compile(r'|\s*') _SOL_OPEN = re.compile(r'', re.IGNORECASE) _SOL_CLOSE = re.compile(r'', re.IGNORECASE) _SOL_END_RE = re.compile(r'', re.IGNORECASE) _EXAMPLE_RE = re.compile(r'^\s*###\s*Example[^\n]*', re.MULTILINE) _PROBLEM_RE = re.compile(r'', re.IGNORECASE) _SOLUTION_OPEN_RE = re.compile(r'') _TAG_ONLY_RE = re.compile(r'^$') _SPLIT_AFTER_COLOR_CLOSE_TO_NEW_ITEM = re.compile( r'^(?P.*?)(?:\s*)(?P(?P\s*)(?P\d+\.)\s+.*)$' ) _BLANKPARA_SPLIT = re.compile(r'(\n\s*\n+)') _PYFUNC_RE = re.compile(r'') _GGB_SINGLE_RE = re.compile(r'^\s*\s*$', re.S) _ICON_SINGLE_RE = re.compile(r'^\s*\s*$', re.S | re.I) _CARD_RE = re.compile(r'(?P.*?)', re.S) _FLOW_RE = re.compile(r'(?P.*?)', re.S) _STEP_RE = re.compile(r'(?P.*?)', re.S) QUIZ_OPEN = re.compile(r'') QUIZ_END = re.compile(r'') STEP_RE_SIMPLE = re.compile(r'\s*(.*)') _FILE_MARKER_RE = re.compile(r'', re.IGNORECASE) # 全域原始 MD(供 AI prompt 查詢用,由 md_io._export_one 注入) _ROOT_MD: str = "" # flow id 計數器 _FLOW_SEQ: int = 0 # --------------------------------------------------------------------------- # AI Prompt 輔助 # --------------------------------------------------------------------------- def build_ai_prompt_for_example(full_md: str, example_seq: int) -> str: """回傳第 example_seq 個 Example 標題到第一個 之前的文字。""" if not full_md or example_seq <= 0: return "" ex_iter = list(_EXAMPLE_RE.finditer(full_md)) if example_seq > len(ex_iter): return "" ex_m = ex_iter[example_seq - 1] sol_m = _SOLUTION_OPEN_RE.search(full_md, pos=ex_m.start()) end = sol_m.start() if sol_m else len(full_md) return full_md[ex_m.start():end].strip() def build_ai_prompt_for_solution_pos(full_md: str, pos: int) -> str: """solution block 的 AI prompt:從最近的 Example 到 pos,若中間有 solution_end 則回空。""" if not full_md or pos is None or pos < 0: return "" search_region = full_md[:pos] last_example = None for m in _EXAMPLE_RE.finditer(search_region): last_example = m if not last_example: return "" seg = search_region[last_example.end():] if _SOL_END_RE.search(seg): return "" return full_md[last_example.start():pos].strip() def build_ai_prompt_for_pos(full_md: str, pos: int) -> str: """給定 pos,往上找最近的 ### Example 或 ,抽出 prompt 文字。""" if not full_md or pos is None or pos < 0: return "" search_region = full_md[:pos] last_example = None for m in _EXAMPLE_RE.finditer(search_region): last_example = m last_problem = None for m in _PROBLEM_RE.finditer(search_region): last_problem = m if not last_example and not last_problem: return "" if last_example: seg = search_region[last_example.end():] if _SOL_END_RE.search(seg): last_example = None if not last_example and not last_problem: return "" if last_example and (not last_problem or last_example.start() > last_problem.start()): start = last_example.start() else: start = last_problem.end() return full_md[start:pos].strip() # --------------------------------------------------------------------------- # TOC / ID 相關 # --------------------------------------------------------------------------- def _make_unique_id(base: str, used: set) -> str: hid, i = base, 1 while hid in used: i += 1 hid = f"{base}-{i}" used.add(hid) return hid def _slugify_heading(text: str) -> str: t = unicodedata.normalize("NFKC", text) t = re.sub(r'\s+', '-', t.strip()) t = re.sub(r'[^A-Za-z0-9\-_]+', '-', t) t = re.sub(r'-{2,}', '-', t).strip('-') return t or "sec" def assign_ids_and_build_toc(nodes: list[dict]) -> list[dict]: """為 heading 節點指派 id,並回傳 TOC 清單。""" current_tag, tag_counters, used_ids, toc = None, {}, set(), [] for n in nodes: if n.get('type') == 'comment': m = _TAG_ONLY_RE.match(n.get('content', '').strip()) if m: current_tag = m.group(1) tag_counters.setdefault(current_tag, 0) continue if n.get('type') == 'heading': title = n.get('content', '').strip() if current_tag: tag_counters[current_tag] += 1 base = f"{current_tag}_id{tag_counters[current_tag]}" else: base = _slugify_heading(title) hid = _make_unique_id(base, used_ids) n['id'] = hid toc.append({'label': title, 'id': hid, 'level': n.get('level', 2)}) return toc def split_blocks_into_sections(blocks: list[dict]) -> list[dict]: """按 H2 heading 把 blocks 切成 sections,第一個為「全部」。""" sections: list[dict] = [{"id": "sec-all", "title": "全部", "blocks": blocks[:]}] current, sec_idx = None, 0 for b in blocks: if b.get("type") == "heading" and int(b.get("level", 0)) == 2: if current: sections.append(current) sec_idx += 1 current = { "id": f"sec-{sec_idx}", "title": (b.get("content") or f"段落 {sec_idx}").strip(), "blocks": [b], } else: if current is not None: current["blocks"].append(b) if current: sections.append(current) return sections # --------------------------------------------------------------------------- # 段落 / 清單解析 # --------------------------------------------------------------------------- def _split_midline_new_item(line: str) -> tuple[str | None, str | None]: m = _SPLIT_AFTER_COLOR_CLOSE_TO_NEW_ITEM.match(line) if not m: return None, None return m.group('left'), m.group('right') def _emit_blank_paragraph(parsed_content: list) -> None: parsed_content.append({'type': 'paragraph', 'content': '', 'runs': [], 'newline': 'true'}) def _append_blankline_runs(blank_token: str, out_runs: list) -> None: n = max(1, blank_token.count('\n') - 1) for _ in range(n): out_runs.append({'text': '', 'newline': 'true', '_blank': '1'}) def parse_line_color_aware(line: str, color_stack: list) -> list[dict]: """把一行切成 runs,依 color_stack 上色,處理 標記。""" pos, out = 0, [] def cur_color(): return color_stack[-1] if color_stack else None for m in _COLOR_TAG.finditer(line): chunk = line[pos:m.start()] if chunk: for tk in _split_text_and_any_display_math(chunk): if tk.get("kind") == "math_block": run = {'latex': tk["latex"], 'kind': 'math_block'} if cur_color(): run['color'] = cur_color() attach_theme_colors(run) out.append(run) else: runs = build_inline_runs(tk["text"] or "") if cur_color(): for r in runs: r.setdefault('color', cur_color()) attach_theme_colors(r) out.extend(runs) if m.group(1): color_stack.append(m.group(1)) else: name = m.group(2) for k in range(len(color_stack) - 1, -1, -1): if color_stack[k] == name: color_stack.pop(k); break pos = m.end() tail = line[pos:] if tail: for tk in _split_text_and_any_display_math(tail): if tk.get("kind") == "math_block": run = {'latex': tk["latex"], 'kind': 'math_block'} if cur_color(): run['color'] = cur_color() attach_theme_colors(run) out.append(run) else: runs = build_inline_runs(tk["text"] or "") if cur_color(): for r in runs: r.setdefault('color', cur_color()) attach_theme_colors(r) out.extend(runs) return out def parse_paragraph_runs_color_aware(block_text: str) -> list[dict]: """多行段落 → runs(含 $$ block math、硬換行)。""" lines = (block_text or "").splitlines(keepends=True) i, color_stack, runs = 0, [], [] PARA_DOLLAR_LINE = re.compile(r'^\s*\$\$(.*)$') while i < len(lines): ln = lines[i] m_dollar = PARA_DOLLAR_LINE.match(ln.rstrip('\r\n')) if m_dollar: latex_lines, first = [], m_dollar.group(1) if re.search(r'\$\$\s*$', first): latex_lines.append(re.sub(r'\$\$\s*$', '', first)) else: i += 1 while i < len(lines): seg = lines[i].rstrip('\r\n') if re.search(r'\$\$\s*$', seg): latex_lines.append(re.sub(r'\$\$\s*$', '', seg)); break latex_lines.append(seg); i += 1 latex = "\n".join(latex_lines).strip() run = {'latex': _ensure_block_dollars(latex), 'kind': 'math_block'} if color_stack: run['color'] = color_stack[-1] attach_theme_colors(run) runs.append(run); i += 1; continue raw = ln if re.search(r'[ ]{2,}\r?\n$', raw): body = re.sub(r'[ ]{2,}\r?\n$', '', raw) runs.extend(parse_line_color_aware(body, color_stack)) _append_hard_break(runs) else: runs.extend(parse_line_color_aware(raw.rstrip('\r\n'), color_stack)) i += 1 return normalize_runs(runs) def parse_list_items(list_block: str) -> list[dict]: """解析清單 block,回傳帶 indent / marker / runs / content 的 item 清單。""" lines, i = list_block.splitlines(), 0 list_items, color_stack = [], [] INDENT_PER_LEVEL = 2 item_re = re.compile(r'^(?P\s*)(?P[*+\-]|\d+\.)\s+(?P.*)$') def _parse_dollar_block(start_i): """從 lines[start_i] 開始讀 $$...$$ block,回傳 (latex_str, next_i)。""" m_d = re.match(r'^\s*\$\$(.*)$', lines[start_i]) if not m_d: return None, start_i latex_lines, first = [], m_d.group(1) if re.search(r'\$\$\s*$', first): latex_lines.append(re.sub(r'\$\$\s*$', '', first)) return "\n".join(latex_lines).strip(), start_i + 1 j = start_i + 1 while j < len(lines): seg = lines[j] if re.search(r'\$\$\s*$', seg): latex_lines.append(re.sub(r'\$\$\s*$', '', seg)); j += 1; break latex_lines.append(seg); j += 1 return "\n".join(latex_lines).strip(), j while i < len(lines): line = lines[i] m = item_re.match(line) if not m: if list_items and line.strip(): left, right = _split_midline_new_item(line) if right: list_items[-1]['runs'].extend(parse_line_color_aware((left or '').strip(), color_stack)) list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs']) lines.insert(i + 1, right); i += 1; continue latex, next_i = _parse_dollar_block(i) if latex is not None: run = {'latex': _ensure_block_dollars(latex), 'kind': 'math_block'} if color_stack: run['color'] = color_stack[-1]; attach_theme_colors(run) list_items[-1]['runs'].append(run) list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs']) i = next_i; continue list_items[-1]['runs'].extend(parse_line_color_aware(line.strip(), color_stack)) list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs']) i += 1; continue color_stack = [] raw_indent = m.group('indent') indent_spaces = raw_indent.expandtabs(INDENT_PER_LEVEL) indent = len(indent_spaces) // INDENT_PER_LEVEL marker = m.group('marker') content = m.group('content').rstrip() runs = parse_line_color_aware(content, color_stack) list_items.append({'indent': indent, 'marker': marker, 'runs': runs, 'content': _plain_text_from_runs(runs)}) i += 1 while i < len(lines) and not item_re.match(lines[i]): ln = lines[i] if not ln.strip(): i += 1; continue left, right = _split_midline_new_item(ln) if right: list_items[-1]['runs'].extend(parse_line_color_aware((left or '').strip(), color_stack)) list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs']) lines.insert(i + 1, right); i += 1; continue latex, next_i = _parse_dollar_block(i) if latex is not None: run = {'latex': _ensure_block_dollars(latex), 'kind': 'math_block'} if color_stack: run['color'] = color_stack[-1]; attach_theme_colors(run) list_items[-1]['runs'].append(run) list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs']) i = next_i; continue list_items[-1]['runs'].extend(parse_line_color_aware(ln.strip(), color_stack)) list_items[-1]['content'] = _plain_text_from_runs(list_items[-1]['runs']) i += 1 return list_items # --------------------------------------------------------------------------- # 特殊 marker 解析 # --------------------------------------------------------------------------- def _ggb_id_from_url(u: str) -> str | None: m = re.search(r'/m/([A-Za-z0-9]+)', u or '') return m.group(1) if m else None def _ggb_node_from_attrs(attrs: dict) -> dict: gid = (attrs.get("id") or "").strip() if not gid and "url" in attrs: gid = _ggb_id_from_url(attrs.get("url", "")) or "" return { "type": "geogebra", "id": gid, "width": attrs.get("w") or attrs.get("width") or "100%", "height": attrs.get("h") or attrs.get("height") or "480", "border": attrs.get("border") or "0", "caption": attrs.get("caption") or "", "card": attrs.get("card") or "0", } def parse_geogebra_marker(s: str) -> dict: m = _GGB_SINGLE_RE.search(s or '') if not m: return {"type": "paragraph", "content": s} attrs = _attrs_to_dict(m.group('attrs') or "") gid = attrs.get("id") or _ggb_id_from_url(attrs.get("url", "")) return { "type": "geogebra", "id": (gid or "").strip(), "width": attrs.get("w") or attrs.get("width") or "100%", "height": attrs.get("h") or attrs.get("height") or "480", "border": attrs.get("border") or "0", "caption": attrs.get("caption") or "", "card": attrs.get("card") or "0", } def parse_icon_marker(s: str) -> dict: """解析 為 icon 節點。""" m = _ICON_SINGLE_RE.search(s or "") if not m: return {"type": "paragraph", "content": s} attrs_str = (m.group("attrs") or "").strip() attrs = _attrs_to_dict(attrs_str) icon = (attrs.get("icon") or attrs.get("name") or "").strip() height = (attrs.get("height") or attrs.get("h") or "18").strip() if not icon: m2 = re.search(r'=\s*"([^"]+)"', attrs_str) if m2: icon = m2.group(1).strip() elif attrs_str: icon = attrs_str.strip() return {"type": "icon", "icon": icon, "height": height} def parse_pyfunc_marker(s: str) -> dict: m = _PYFUNC_RE.search(s or '') if not m: return {"type": "paragraph", "content": s} attrs = _attrs_to_dict(m.group('attrs') or "") kwargs: dict = {} if 'params' in attrs: try: kwargs = json.loads(attrs['params']) except Exception: kwargs = {} for k, v in attrs.items(): if k not in ('path', 'call', 'params'): kwargs.setdefault(k, v) return { "type": "pyfunc", "path": (attrs.get("path") or "").strip(), "call": (attrs.get("call") or "make_view").strip(), "kwargs": kwargs, } def _parse_color_pair(token: str) -> tuple[str, str]: s = (token or "").strip() if not s: return "", "" if "|" in s: a, b = s.split("|", 1) return _norm_hex(a.strip()), _norm_hex(b.strip()) c = _norm_hex(s) return c, c def parse_card_block(src: str) -> dict: m = _CARD_RE.search(src) if not m: return {"type": "card", "content": src} attrs = _attrs_to_dict(m.group("attrs") or "") inner = (m.group("body") or "").strip() kind = attrs.get("type") or attrs.get("kind") or "" headline = attrs.get("headline") or attrs.get("title") or "" if not (kind and headline): lines = inner.splitlines() nz = [i for i, ln in enumerate(lines) if ln.strip()] if nz: if not kind: kind = lines[nz[0]].strip(); lines[nz[0]] = "" if len(nz) >= 2 and not headline: headline = lines[nz[1]].strip(); lines[nz[1]] = "" inner = "\n".join(lines).lstrip() bg_l, bg_d = _parse_color_pair(attrs.get("bg") or attrs.get("background") or attrs.get("body_bg") or "") fg_l, fg_d = _parse_color_pair(attrs.get("fg") or attrs.get("text") or attrs.get("header_fg") or "") accent = (attrs.get("accent") or attrs.get("badge") or attrs.get("palette") or "").strip() body_blocks = parse_content_block(inner) node = {"type": "card", "kind": kind, "headline": headline, "body": body_blocks} if bg_l or bg_d: node["bg_light"], node["bg_dark"] = bg_l, bg_d if fg_l or fg_d: node["fg_light"], node["fg_dark"] = fg_l, fg_d if accent: node["accent"] = accent return node def _split_mcq_answers(ans_raw: str) -> list[str]: raw = (ans_raw or "").strip().lower() if not raw: return [] raw = raw.replace(",", ",").replace("、", ",").replace(";", ",").replace("|", ",").replace(" ", ",") raw = raw.strip(",") if not raw: return [] if re.fullmatch(r"[abcd]+", raw) and "," not in raw: tokens = list(raw) else: tokens = [t for t in re.split(r"[,\s]+", raw) if t] idxmap = {"1": "a", "2": "b", "3": "c", "4": "d"} out = [] for t in tokens: t = t.strip().lower().rstrip(".") t = idxmap.get(t, t) if t in ("a", "b", "c", "d") and t not in out: out.append(t) return out def parse_interactive_block(src: str) -> dict: inner = re.sub(r'^\s*\s*', '', src, flags=re.S) inner = re.sub(r'\s*$', '', inner, flags=re.S) m_fill_zh = re.search(r'(.*?)', inner, re.S) m_fill_en = re.search(r'(.*?)', inner, re.S) m_fill = m_fill_en or m_fill_zh m_mcq_old = re.search(r'(.*?)', inner, re.S) m_mcq_new = re.search(r'(.*?)', inner, re.S) m_mcq = m_mcq_new or m_mcq_old beg = min([m.start() for m in [m_fill, m_mcq] if m] or [len(inner)]) prompt_md = inner[:beg].rstrip() prompt_runs: list[dict] = [] if prompt_md: for part in _BLANKPARA_SPLIT.split(prompt_md): if not part: continue if _BLANKPARA_SPLIT.fullmatch(part): _append_blankline_runs(part, prompt_runs) else: runs = parse_paragraph_runs_color_aware(part) or [] for r in runs: r['newline'] = 'false' prompt_runs.extend(runs) if m_fill: attrs = _attrs_to_dict(m_fill.group(1) or "") body = (m_fill.group(2) or "").strip() right = attrs.get("right", ""); wrong = attrs.get("wrong", "") ans = attrs.get("ans") or (body.splitlines()[0].strip() if body else "") answers = [s.strip() for s in ans.split("|") if s.strip()] normalize = [s.strip() for s in (attrs.get("normalize") or "sym,numeric,nospace").split(",") if s.strip()] if not prompt_runs and body: for p_idx, para in enumerate(re.split(r'\n\s*\n', body)): runs = parse_paragraph_runs_color_aware(para) is_last = (p_idx == len(re.split(r'\n\s*\n', body)) - 1) for r in runs: r.setdefault("newline", "false" if is_last else "true") prompt_runs.extend(runs) return { "type": "quiz", "qtype": "fill", "id": attrs.get("id", ""), "prompt_runs": prompt_runs, "answers": answers, "normalize": normalize, "placeholder": attrs.get("placeholder", ""), "border": attrs.get("border", "1"), "inline_math": attrs.get("inline_math", "1"), "right_msg": right, "wrong_msg": wrong, "card": attrs.get("card", "1"), } if m_mcq: attrs = _attrs_to_dict(m_mcq.group(1) or "") body = (m_mcq.group(2) or "").strip() right = attrs.get("right", ""); wrong = attrs.get("wrong", "") hints = {} for k, v in attrs.items(): kk = (k or "").strip().lower() if kk.startswith("hint_"): opt = kk.split("_", 1)[1].strip().lower() if opt in ("a", "b", "c", "d") and str(v).strip(): hints[opt] = str(v).strip() indent_flag = attrs.get("indent") or attrs.get("indent_options") lines = body.splitlines() lead_lines, opt_lines, seen_option = [], [], False for ln in lines: t = ln.strip() if re.match(r'^[aAdDbBcC]\s*\.', t): seen_option = True; opt_lines.append(t) else: (opt_lines if seen_option else lead_lines).append(ln) if not prompt_runs and lead_lines: paragraphs = re.split(r'\n\s*\n', "\n".join(lead_lines).rstrip()) for p_idx, para in enumerate(paragraphs): runs = parse_paragraph_runs_color_aware(para) is_last = (p_idx == len(paragraphs) - 1) for r in runs: r.setdefault("newline", "false" if is_last else "true") prompt_runs.extend(runs) opts = [] for t in (opt_lines if opt_lines else lines): tt = t.strip() for key, pat in [("a", r'^[aA]\s*\.'), ("b", r'^[bB]\s*\.'), ("c", r'^[cC]\s*\.'), ("d", r'^[dD]\s*\.')]: if re.match(pat, tt): opts.append({"key": key, "text": tt.split('.', 1)[1].strip()}) multi_flag = attrs.get("multi") or attrs.get("multiple") or attrs.get("checkbox") is_multi = str(multi_flag).strip().lower() in ("1", "true", "yes", "y", "on") ans_raw = (attrs.get("ans", "") or "").strip().lower().rstrip(".") ans_list = _split_mcq_answers(ans_raw) ans_one = ans_list[0] if ans_list else "" node = { "type": "quiz", "qtype": "multi" if is_multi else "choice", "id": attrs.get("id", ""), "prompt_runs": prompt_runs, "options": opts, "shuffle": attrs.get("shuffle", "0") in ("1", "true", "yes"), "explain": attrs.get("explain", ""), "right_msg": right, "wrong_msg": wrong, "wrong_hints": hints, "card": attrs.get("card", "1"), } if is_multi: node["answers"] = ans_list; node["answer"] = "" else: node["answer"] = ans_one if indent_flag is not None: node["indent_options"] = indent_flag return node return {"type": "paragraph", "content": prompt_md, "runs": prompt_runs} def _next_flow_id() -> str: global _FLOW_SEQ _FLOW_SEQ += 1 return f"flow-{_FLOW_SEQ}" def parse_flow_block(src: str) -> dict: m = _FLOW_RE.search(src) if not m: return {"type": "paragraph", "content": src} fattrs = _attrs_to_dict(m.group("attrs") or "") body = (m.group("body") or "").strip() flow_id = fattrs.get("id") or _next_flow_id() persist = (fattrs.get("persist") or "").strip() steps_raw = list(_STEP_RE.finditer(body)) if not steps_raw: steps_raw_iter = [("", body)] def _unwrap(x): return x[0], x[1] else: steps_raw_iter = steps_raw def _unwrap(mm): return (mm.group("attrs") or ""), (mm.group("body") or "").strip() steps, quiz_seq, prev_gate_qid = [], 0, None for i, raw in enumerate(steps_raw_iter, start=1): sattrs_raw, sbody = _unwrap(raw) sattrs = _attrs_to_dict(sattrs_raw) step_id = sattrs.get("id") or f"step-{i}" title = sattrs.get("title") or f"步驟 {i}" cond = (sattrs.get("cond") or "all").lower().strip() content_blocks = parse_document(sbody) first_quiz_id = None for b in content_blocks: if b.get("type") == "quiz": if not b.get("id"): quiz_seq += 1 b["id"] = f"{flow_id}::{step_id}::q{quiz_seq}" b["flow"] = flow_id; b["step"] = step_id if first_quiz_id is None: first_quiz_id = b["id"] steps.append({"id": step_id, "title": title, "cond": cond, "content": content_blocks, "gate_from": prev_gate_qid}) if first_quiz_id is not None: prev_gate_qid = first_quiz_id return {"type": "flow", "id": flow_id, "persist": persist, "steps": steps} def parse_solution_block(src: str) -> dict: m_open = _SOL_OPEN.search(src) m_close = _SOL_CLOSE.search(src, m_open.end() if m_open else 0) if not (m_open and m_close): return {"type": "paragraph", "content": src} attrs_raw = (m_open.group("attrs") or "").strip() shortcut_label = "" attrs_for_dict = attrs_raw m_short = re.match(r'^_([^\s]+)(.*)$', attrs_raw) if m_short: shortcut_label = (m_short.group(1) or "").strip() attrs_for_dict = (m_short.group(2) or "").strip() attrs = _attrs_to_dict(attrs_for_dict) inner = src[m_open.end():m_close.start()] body_blocks = [b for b in parse_document(inner) if b.get('type') != 'comment'] label = ((attrs.get("label") or shortcut_label or "").strip()) or "看解法" default = (attrs.get("default", "closed") or "closed").lower() persist = (attrs.get("persist", "none") or "none").lower() card = str(attrs.get("card", "0")) accent = attrs.get("accent", "") after = attrs.get("after", "") h = hashlib.md5() for part in [inner, label, default, persist, card, accent, after]: h.update((part or "").encode("utf-8")) h.update(b"|") uid = f"sol_{h.hexdigest()[:10]}" return { "type": "solution", "uid": uid, "label": label, "default": default, "persist": persist, "card": card, "accent": accent, "after": after, "body": body_blocks, } def parse_quizzes(md: str) -> list[dict]: """解析 ... 區塊(order 題型)。""" out, i = [], 0 while True: m = QUIZ_OPEN.search(md, i) if not m: break n = QUIZ_END.search(md, m.end()) if not n: break block = md[m.end():n.start()] attrs = _parse_attrs(m.group('attrs') or "") qtype = (attrs.get("qtype") or "").strip().lower() or "multi" title = (attrs.get("title") or "練習題").strip() card = 1 if str(attrs.get("card", "1")).lower() in ("1", "true", "yes") else 0 shuffle = str(attrs.get("shuffle", "0")).lower() in ("1", "true", "yes") prompt_md = "" for ln in block.splitlines(): s = ln.strip() if s and not s.startswith(".*?|' r'.*?|' r'.*?|' r'.*?|' r'|' r'|' r'.*?|' r'.*?|' r'.*?|' r'.*?|' r'.*?|' r'|' r'^\s*\s*$|' r'', re.DOTALL, ) for match in re.finditer(pattern, entire_markdown_text): start, end = match.span() pre_text = entire_markdown_text[last_match_end:start] if pre_text: parsed_results.extend(parse_content_block(pre_text)) content = match.group(0) if content.lstrip().startswith('