Spaces:
Running
Running
| import json | |
| import re | |
| from pathlib import Path | |
| import streamlit as st | |
| from cert_study_app.config import DEFAULT_USER | |
| from cert_study_app.db import SessionLocal | |
| from cert_study_app.services.concept_note_service import ConceptNoteService | |
| from cert_study_app.services.question_type_metadata_service import normalize_question_type | |
| from cert_study_app.services.quiz_service import yes_no_labels | |
| from cert_study_app.ui.common import DEFAULT_FAST_MODEL | |
| PARENT_STEM_HEADINGS: frozenset[str] = frozenset({ | |
| "κ°μ", | |
| "μΌλ° κ°μ", | |
| "κΈ°μ‘΄ νκ²½", | |
| "νκ²½", | |
| "μꡬμ¬ν", | |
| "μꡬ μ¬ν", | |
| "κ³νλ λ³κ²½", | |
| "κΈ°μ μꡬ μ¬ν", | |
| "μ¬μ©μ μꡬ μ¬ν", | |
| "μΈμ¦ μꡬ μ¬ν", | |
| "λΆμ μꡬ μ¬ν", | |
| "λ€νΈμν¬ μΈνλΌ", | |
| "Active Directory νκ²½", | |
| "λΌμ΄μΌμ€ λ¬Έμ ", | |
| "λ¬Έμ μ€λͺ ", | |
| }) | |
| def render_question_image(question): | |
| image_path = question.get("image_path") | |
| if image_path and Path(image_path).exists(): | |
| key = f"show_image_v2_{question.get('id')}" | |
| visual_types = {"hotspot", "table_choice", "ordering", "matching"} | |
| label = "λ¬Έμ κ·Έλ¦Ό 보기" if (question.get("question_type") or "").lower() in visual_types else "μλ¬Έ μ΄λ―Έμ§ 보기" | |
| show_image = st.toggle(label, key=key, value=False) | |
| if show_image: | |
| st.image(image_path, use_container_width=True) | |
| def display_question_text(text: str) -> str: | |
| return re.sub(r"^\s*\d{1,3}\s*[.)]\s*", "", text or "", count=1).strip() | |
| def display_parent_text(text: str) -> str: | |
| lines = [] | |
| for raw_line in (text or "").splitlines(): | |
| line = raw_line.strip() | |
| if not line: | |
| continue | |
| topic_match = re.match(r"^\s*\d{1,3}\s*[.)]\s*(μ£Όμ \s+\d+\s*,?\s*.+)$", line) | |
| if topic_match: | |
| line = topic_match.group(1).strip() | |
| if re.search(r"\d{1,3}\s*[~ο½-]\s*\d{1,3}\s*λ²\s*λ¬Έμ \)?", line): | |
| continue | |
| if re.fullmatch(r"\(?\s*\d{1,3}\s*[~ο½-]\s*\d{1,3}\s*\)?", line): | |
| continue | |
| lines.append(line) | |
| return "\n".join(lines).strip() | |
| def group_start_number(group_id: str): | |
| match = re.match(r"q(\d{1,3})(?:-|$)", group_id or "") | |
| return int(match.group(1)) if match else None | |
| def is_first_group_question(question) -> bool: | |
| start = group_start_number(question.get("group_id") or "") | |
| return bool(start and int(question.get("number") or 0) == start) | |
| def split_parent_sections(text: str) -> list[tuple[str, str]]: | |
| lines = [line.strip() for line in display_parent_text(text).splitlines() if line.strip()] | |
| sections = [] | |
| title = "μμ½" | |
| body = [] | |
| for line in lines: | |
| normalized = line.rstrip(":") | |
| is_heading = normalized in PARENT_STEM_HEADINGS or ( | |
| len(normalized) <= 24 and any(keyword in normalized for keyword in ["μꡬ", "νκ²½", "κ°μ", "λ¬Έμ "]) | |
| ) | |
| if is_heading and body: | |
| sections.append((title, "\n".join(body).strip())) | |
| title = normalized | |
| body = [] | |
| elif is_heading: | |
| title = normalized | |
| else: | |
| body.append(line) | |
| if body: | |
| sections.append((title, "\n".join(body).strip())) | |
| return [(title, body) for title, body in sections if body] or [("μ 체", display_question_text(text))] | |
| def format_parent_stem(text: str) -> str: | |
| rendered = [] | |
| for raw_line in display_parent_text(text).splitlines(): | |
| line = raw_line.strip() | |
| if not line: | |
| continue | |
| normalized = line.rstrip(":") | |
| is_heading = normalized in PARENT_STEM_HEADINGS or ( | |
| len(normalized) <= 24 and any(keyword in normalized for keyword in ["μꡬ", "νκ²½", "κ°μ", "λ¬Έμ "]) | |
| ) | |
| if is_heading: | |
| rendered.append(f"\n**{normalized}**") | |
| elif line.startswith(("β’", "β", "-", "β ", "β‘", "β’", "β£")): | |
| rendered.append(f"- {line.lstrip('β’β- ').strip()}") | |
| else: | |
| rendered.append(line) | |
| return "\n\n".join(rendered).strip() | |
| def render_question_header(question, context_label=None): | |
| number = question.get("number") or question.get("id") | |
| meta = [f"λ¬Έμ {number}λ²"] | |
| if context_label: | |
| meta.append(context_label) | |
| elif question.get("concept_label") and question.get("category"): | |
| meta.append(question["concept_label"]) | |
| if question.get("source"): | |
| meta.append(question["source"]) | |
| if question.get("page"): | |
| meta.append(f"p.{question['page']}") | |
| st.markdown(f"### {meta[0]}") | |
| if len(meta) > 1: | |
| st.caption(" Β· ".join(meta[1:])) | |
| if question.get("concept_tags"): | |
| st.caption("κ°λ νκ·Έ: " + ", ".join(question["concept_tags"])) | |
| def render_parent_stem(question): | |
| parent_stem = question.get("parent_stem") | |
| parent_image_paths = question.get("parent_image_paths") or [] | |
| has_parent_stem = bool(display_parent_text(parent_stem).strip()) | |
| if has_parent_stem: | |
| if st.toggle("κ³΅ν΅ μ§λ¬Έ 보기", key=f"show_parent_v2_{question.get('id')}", value=is_first_group_question(question)): | |
| st.markdown(format_parent_stem(parent_stem)) | |
| if has_parent_stem and parent_image_paths: | |
| if st.toggle("κ³΅ν΅ μ§λ¬Έ μλ¬Έ νμ΄μ§ 보기", key=f"show_parent_images_v2_{question.get('id')}", value=False): | |
| for index, image_path in enumerate(parent_image_paths, 1): | |
| if Path(image_path).exists(): | |
| st.caption(f"κ³΅ν΅ μ§λ¬Έ μλ¬Έ {index}/{len(parent_image_paths)}") | |
| st.image(image_path, use_container_width=True) | |
| def render_answer_result(result): | |
| if result["correct"]: | |
| st.success("μ λ΅μ λλ€.") | |
| else: | |
| st.error(f"μ€λ΅μ λλ€. μ λ΅: {result['answer']}") | |
| if result.get("explanation"): | |
| st.markdown('<div class="answer-explanation">', unsafe_allow_html=True) | |
| st.markdown(result["explanation"]) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| def render_concept_candidates(question): | |
| import os | |
| key = f"concept_candidates_{question['id']}" | |
| st.markdown("#### κ°λ μ 리") | |
| if st.button("κ°λ ν보 보기", use_container_width=True, key=f"generate_concepts_{question['id']}"): | |
| db = SessionLocal() | |
| try: | |
| service = ConceptNoteService(db) | |
| with st.spinner("qwenμ΄ μ μ₯ν λ§ν κ°λ ν보λ₯Ό μ°Ύλ μ€μ λλ€."): | |
| st.session_state[key] = service.generate_candidates( | |
| question["id"], | |
| model=DEFAULT_FAST_MODEL, | |
| base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"), | |
| ) | |
| except Exception as exc: | |
| st.error(f"κ°λ ν보 μμ± μ€ν¨: {exc}") | |
| finally: | |
| db.close() | |
| candidates = st.session_state.get(key) or [] | |
| if not candidates: | |
| st.caption("νμν κ°λ λ§ μ μ₯ν μ μλλ‘ ν보λ₯Ό λ¨Όμ νμΈν©λλ€.") | |
| return | |
| for index, candidate in enumerate(candidates, 1): | |
| with st.container(border=True): | |
| name = st.text_input("κ°λ λͺ ", value=candidate.get("concept_name", ""), key=f"concept_name_{question['id']}_{index}") | |
| summary = st.text_area("ν΅μ¬ μμ½", value=candidate.get("summary", ""), height=80, key=f"concept_summary_{question['id']}_{index}") | |
| exam_point = st.text_area("μν ν¬μΈνΈ", value=candidate.get("exam_point", ""), height=80, key=f"concept_exam_{question['id']}_{index}") | |
| trap_point = st.text_area("ν·κ°λ¦΄ ν¬μΈνΈ", value=candidate.get("trap_point", ""), height=80, key=f"concept_trap_{question['id']}_{index}") | |
| keywords = st.text_input( | |
| "ν€μλ", | |
| value=", ".join(candidate.get("keywords") or []), | |
| key=f"concept_keywords_{question['id']}_{index}", | |
| ) | |
| if st.button("μ΄ κ°λ μ μ₯", use_container_width=True, key=f"save_concept_{question['id']}_{index}"): | |
| payload = { | |
| "concept_name": name, | |
| "summary": summary, | |
| "exam_point": exam_point, | |
| "trap_point": trap_point, | |
| "keywords": [item.strip() for item in keywords.split(",") if item.strip()], | |
| } | |
| db = SessionLocal() | |
| try: | |
| ConceptNoteService(db).save_candidate(payload, question["id"], DEFAULT_USER) | |
| st.success("κ°λ μ μ μ₯νμ΅λλ€.") | |
| finally: | |
| db.close() | |
| def visual_analysis_data(question) -> dict: | |
| raw = "" | |
| if isinstance(question, dict): | |
| raw = question.get("visual_analysis_json") or "" | |
| else: | |
| raw = getattr(question, "visual_analysis_json", "") or "" | |
| try: | |
| analysis = json.loads(raw or "{}") | |
| except Exception: | |
| return {} | |
| return analysis if isinstance(analysis, dict) else {} | |
| def visual_answer_areas(question) -> list[dict]: | |
| analysis = visual_analysis_data(question) | |
| areas = analysis.get("answer_areas") if isinstance(analysis, dict) else None | |
| if isinstance(areas, list): | |
| return [area for area in areas if isinstance(area, dict)] | |
| return [] | |
| def visual_source_content(question) -> str: | |
| analysis = visual_analysis_data(question) | |
| value = analysis.get("source_content") or analysis.get("source") or analysis.get("evidence") | |
| if isinstance(value, list): | |
| return "\n".join(str(item).strip() for item in value if str(item).strip()) | |
| if isinstance(value, dict): | |
| return json.dumps(value, ensure_ascii=False, indent=2) | |
| return str(value or "").strip() | |
| def visual_answer_areas_to_text(areas: list[dict]) -> str: | |
| lines = [] | |
| for area in areas: | |
| label = str(area.get("label") or area.get("text") or "").strip() | |
| selected = str(area.get("selected_answer") or area.get("answer") or "").strip() | |
| options = area.get("options") or [] | |
| if isinstance(options, str): | |
| options_text = options.strip() | |
| elif isinstance(options, list): | |
| options_text = ", ".join(str(option).strip() for option in options if str(option).strip()) | |
| else: | |
| options_text = "" | |
| if label or options_text or selected: | |
| lines.append(f"{label} | {options_text} | {selected}") | |
| return "\n".join(lines) | |
| def parse_visual_answer_areas_text(text: str) -> list[dict]: | |
| areas = [] | |
| for raw_line in (text or "").splitlines(): | |
| line = raw_line.strip() | |
| if not line: | |
| continue | |
| parts = [part.strip() for part in line.split("|")] | |
| label = parts[0] if len(parts) >= 1 else "" | |
| options_text = parts[1] if len(parts) >= 2 else "" | |
| selected = parts[2] if len(parts) >= 3 else "" | |
| options = [item.strip() for item in re.split(r"\s*,\s*", options_text) if item.strip()] | |
| area = {"label": label, "options": options, "selected_answer": selected} | |
| if label or options or selected: | |
| areas.append(area) | |
| return areas | |
| def visual_selected_answers(areas: list[dict]) -> str: | |
| answers = [] | |
| for area in areas: | |
| label = str(area.get("label") or "").strip() | |
| selected = str(area.get("selected_answer") or area.get("answer") or "").strip() | |
| if selected: | |
| answers.append(f"{label}: {selected}" if label else selected) | |
| return "\n".join(answers) | |
| def remove_duplicate_visual_labels(question_text: str, labels: list[str]) -> str: | |
| lines = [] | |
| normalized_labels = {re.sub(r"\s+", " ", label).strip().lower() for label in labels if label} | |
| for raw_line in (question_text or "").splitlines(): | |
| normalized_line = re.sub(r"\s+", " ", raw_line).strip().lower() | |
| if normalized_line in normalized_labels: | |
| continue | |
| lines.append(raw_line) | |
| text = "\n".join(lines).strip() | |
| text = re.sub(r"\n{3,}", "\n\n", text) | |
| return text.strip() | |
| def visual_statements(question) -> list[dict]: | |
| try: | |
| analysis = json.loads(question.get("visual_analysis_json") or "{}") | |
| except Exception: | |
| return [] | |
| statements = analysis.get("statements") if isinstance(analysis, dict) else None | |
| if isinstance(statements, list): | |
| return [statement for statement in statements if isinstance(statement, dict)] | |
| return [] | |
| def render_question_body(question): | |
| render_parent_stem(question) | |
| question_text = display_question_text(question.get("question") or "") | |
| source_content = visual_source_content(question) | |
| answer_area_labels = [ | |
| str(area.get("label") or "").strip() | |
| for area in visual_answer_areas(question) | |
| if str(area.get("label") or "").strip() | |
| ] | |
| if answer_area_labels: | |
| question_text = remove_duplicate_visual_labels(question_text, answer_area_labels) | |
| if question_text: | |
| st.markdown(question_text.replace("\n", " \n")) | |
| if source_content: | |
| st.markdown("#### λ¬Έμ κ·Όκ±°") | |
| st.code(source_content) | |
| def option_display_and_value(option, index): | |
| raw = str(option).strip() | |
| match = re.match(r"^([A-Za-z]|\d+)\s*[\.\)]\s*(.+)$", raw, re.S) | |
| if match: | |
| value = match.group(1).strip() | |
| text = match.group(2).strip() | |
| return f"{value}. {text}", value | |
| else: | |
| value = str(index) | |
| text = raw | |
| return text, value | |
| def answer_labels(value: str) -> list[str]: | |
| text = str(value or "").strip().upper() | |
| if not text: | |
| return [] | |
| if re.fullmatch(r"[A-Z]{2,26}", text): | |
| return list(text) | |
| tokens = re.findall(r"\b[A-Z]\b|\b[1-9]\b", text) | |
| labels = [] | |
| for token in tokens: | |
| if token.isdigit(): | |
| labels.append(chr(ord("A") + int(token) - 1)) | |
| else: | |
| labels.append(token) | |
| return labels | |
| def is_multi_answer(question) -> bool: | |
| labels = answer_labels(question.get("answer") or "") | |
| if len(set(labels)) > 1: | |
| return True | |
| text = question.get("question") or "" | |
| return bool( | |
| re.search( | |
| r"(λ\s*κ°μ§|μΈ\s*κ°μ§|λ€\s*κ°μ§|λͺ¨λ\s*μ ν|볡μ|κ°κ°\s*μ ν|choose\s+two|choose\s+three|select\s+two|select\s+three)", | |
| text, | |
| re.I, | |
| ) | |
| ) | |
| def is_per_row_choice(question) -> bool: | |
| question_type = normalize_question_type(question.get("question_type")) | |
| if question_type not in {"table_choice", "hotspot", "matching"}: | |
| return False | |
| if visual_answer_areas(question): | |
| return True | |
| labels = answer_labels(question.get("answer") or "") | |
| text = "\n".join( | |
| [ | |
| question.get("question") or "", | |
| question.get("explanation") or "", | |
| question.get("answer") or "", | |
| ] | |
| ) | |
| if re.search(r"(μ΄λ€\s*(λ|μΈ|λ€)\s*κ°μ§|κ°\s*μ λ΅|κ°\s*μ¬λ°λ₯Έ\s*μ ν|choose\s+two|choose\s+three|select\s+two|select\s+three)", text, re.I): | |
| return False | |
| if len(labels) <= 1 and not re.search(r"(?:μμ|Box)\s*1", text, re.I): | |
| return False | |
| return bool(re.search(r"(κ°\s*리μμ€|κ°\s*νλͺ©|κ°\s*ν|λ΅λ³\s*μμ|λλ‘λ€μ΄|μ μ ν\s*μ΅μ |(?:μμ|Box)\s*1)", text, re.I)) | |
| def detected_box_labels(question) -> list[str]: | |
| areas = visual_answer_areas(question) | |
| if areas: | |
| labels = [] | |
| for index, area in enumerate(areas, 1): | |
| label = str(area.get("label") or "").strip() | |
| labels.append(label or f"μμ {index}") | |
| return labels | |
| text = "\n".join( | |
| [ | |
| question.get("question") or "", | |
| question.get("explanation") or "", | |
| question.get("answer") or "", | |
| ] | |
| ) | |
| labels = [] | |
| for match in re.finditer(r"(?:μμ|Box)\s*([0-9]+)", text, re.I): | |
| label = f"μμ {int(match.group(1))}" | |
| if label not in labels: | |
| labels.append(label) | |
| return labels | |
| def box_choice_labels(question, count: int) -> list[str]: | |
| labels = detected_box_labels(question) | |
| if len(labels) >= count: | |
| return labels[:count] | |
| labels = [f"μμ {index + 1}" for index in range(count)] | |
| return labels | |
| def yes_no_answer_labels(value: str) -> list[str]: | |
| return yes_no_labels(value) | |
| def is_yes_no_hotspot(question) -> bool: | |
| question_type = normalize_question_type(question.get("question_type")) | |
| if question_type not in {"yes_no", "hotspot", "table_choice"}: | |
| return False | |
| if question_type == "yes_no": | |
| return True | |
| text = " ".join( | |
| [ | |
| question.get("question") or "", | |
| question.get("answer") or "", | |
| question.get("explanation") or "", | |
| " ".join(str(option) for option in question.get("options") or []), | |
| ] | |
| ) | |
| return bool( | |
| re.search(r"λ€μ\s*κ°\s*(μ§μ |μ€λͺ |νλͺ©)|κ°\s*(μ§μ |μ€λͺ |νλͺ©).*μ|μλ₯Ό\s*μ ν|μλμ€λ₯Ό\s*μ ν|μλμλ₯Ό\s*μ ν", text) | |
| ) | |
| def statement_option_rows(options: list[str], expected_count: int) -> list[str]: | |
| rows = [] | |
| for option in options or []: | |
| text = str(option or "").strip() | |
| if not text: | |
| continue | |
| cleaned = re.sub(r"^\s*(?:[0-9]+|[A-Z])[-.)]\s*", "", text).strip() | |
| if re.fullmatch(r"μ|μλμ€|μλμ|yes|no", cleaned, re.I): | |
| continue | |
| rows.append(cleaned) | |
| if expected_count and len(rows) >= expected_count: | |
| return rows[:expected_count] | |
| return rows | |
| def grouped_option_rows(options: list[str]) -> list[dict]: | |
| grouped = {} | |
| order = [] | |
| for option in options or []: | |
| text = str(option or "").strip() | |
| match = re.match(r"^\s*(\d+)-([A-Z])[\.)]?\s*(.+)$", text, re.I) | |
| if not match: | |
| continue | |
| group_key = match.group(1) | |
| body = match.group(3).strip() | |
| row_label = f"νλͺ© {group_key}" | |
| value = body | |
| if ":" in body: | |
| row_label, value = [part.strip() for part in body.split(":", 1)] | |
| if group_key not in grouped: | |
| grouped[group_key] = {"label": row_label, "options": []} | |
| order.append(group_key) | |
| grouped[group_key]["options"].append(value) | |
| rows = [grouped[key] for key in order] | |
| return rows if len(rows) >= 2 and all(row["options"] for row in rows) else [] | |
| def yes_no_lines(question_text: str) -> list[str]: | |
| lines = [] | |
| for raw_line in (question_text or "").splitlines(): | |
| line = raw_line.strip() | |
| if not line: | |
| continue | |
| if re.match(r"^\d{1,3}\s*[.)]", line): | |
| continue | |
| if any(skip in line for skip in ["μ°Έκ³ :", "λ΅λ³νλ €λ©΄", "μ¬λ°λ₯Έ μ ν", "무μμ", "μ΄κ²μ΄ λͺ©ν"]): | |
| continue | |
| if re.search(r"(=|μ|μλμ€|μ μμ΅λλ€|ν΄μΌ ν©λλ€|μ§μ|νμ©|κ°λ₯)", line): | |
| cleaned = re.sub(r"^\s*[β’ββ β‘β’β£β€\-\d.)]+\s*", "", line).strip() | |
| if len(cleaned) >= 8: | |
| lines.append(cleaned) | |
| return lines[-4:] | |
| def render_grouped_option_selects(question, key_prefix, rows): | |
| selections = [] | |
| all_yes_no = all( | |
| all(str(option).strip().lower() in {"yes", "no", "μ", "μλμ€", "μλμ"} for option in row["options"]) | |
| for row in rows | |
| ) | |
| st.markdown("#### νλͺ©λ³ λ΅μ") | |
| for index, row in enumerate(rows, 1): | |
| options = [str(option).strip() for option in row["options"] if str(option).strip()] | |
| selected = st.selectbox( | |
| row["label"] or f"νλͺ© {index}", | |
| ["μ ν μ ν¨"] + options, | |
| key=f"{key_prefix}_grouped_options_{question['id']}_{index}", | |
| ) | |
| if selected != "μ ν μ ν¨": | |
| if all_yes_no: | |
| selections.append("Y" if selected.lower() in {"yes", "μ"} else "N") | |
| else: | |
| selections.append(selected) | |
| return ",".join(selections) if len(selections) == len(rows) else None | |
| def render_yes_no_matrix(question, key_prefix, rows, caption="μ§μ λ³ λ΅μ"): | |
| selections = [] | |
| st.markdown(f"#### {caption}") | |
| for index, row in enumerate(rows, 1): | |
| selected = st.radio( | |
| row, | |
| ["μ", "μλμ€"], | |
| index=None, | |
| horizontal=True, | |
| key=f"{key_prefix}_yn_matrix_{question['id']}_{index}", | |
| ) | |
| if selected: | |
| selections.append("Y" if selected == "μ" else "N") | |
| return ",".join(selections) if len(selections) == len(rows) else None | |
| def render_answer_input(question, key_prefix): | |
| options = question["options"] | |
| question_type = normalize_question_type(question.get("question_type")) | |
| statement_rows = [ | |
| str(statement.get("text") or "").strip() | |
| for statement in visual_statements(question) | |
| if str(statement.get("text") or "").strip() | |
| ] | |
| if statement_rows: | |
| return render_yes_no_matrix(question, f"{key_prefix}_statements", statement_rows) | |
| grouped_rows = grouped_option_rows(options) | |
| if grouped_rows: | |
| return render_grouped_option_selects(question, key_prefix, grouped_rows) | |
| if is_yes_no_hotspot(question): | |
| answer_count = len(yes_no_answer_labels(question.get("answer") or "")) or len( | |
| yes_no_answer_labels(question.get("explanation") or "") | |
| ) | |
| row_count = max(3, answer_count, len(statement_rows)) | |
| rows = statement_option_rows(options, row_count) or yes_no_lines(question.get("question") or "") | |
| if statement_rows: | |
| rows = statement_rows | |
| if len(rows) < row_count: | |
| rows = [f"μ§μ {index + 1}" for index in range(row_count)] | |
| return render_yes_no_matrix(question, f"{key_prefix}_hotspot", rows[:row_count]) | |
| if not options: | |
| answer = str(question.get("answer") or "").upper() | |
| if question_type in {"yes_no", "table_choice", "hotspot"} and re.search(r"(μ|μλμ€|μλμ|YES|NO|Y|N)", answer, re.I): | |
| rows = yes_no_lines(question.get("question") or "") or ["μ§μ 1", "μ§μ 2", "μ§μ 3"] | |
| return render_yes_no_matrix(question, key_prefix, rows) | |
| st.warning("μ΄ λ¬Έμ λ λ³΄κΈ°κ° μμ§ κ΅¬μ‘°νλμ§ μμ νμ΄μμ μ μΈν΄μΌ ν©λλ€. λ¬Έμ κ²μμμ 보기/μμ/μ§μ μ λ¨Όμ μ λ¦¬ν΄ μ£ΌμΈμ.") | |
| return None | |
| display_to_value = {} | |
| display_options = [] | |
| for index, option in enumerate(options, 1): | |
| display, value = option_display_and_value(option, index) | |
| display_options.append(display) | |
| display_to_value[display] = value | |
| if is_per_row_choice(question): | |
| areas = visual_answer_areas(question) | |
| detected_box_count = len(detected_box_labels(question)) | |
| expected_count = max(2, len(answer_labels(question.get("answer") or "")), min(detected_box_count, 8)) | |
| selections = [] | |
| row_labels = box_choice_labels(question, expected_count) | |
| st.markdown("#### μμλ³ λ΅μ") | |
| for index, row_label in enumerate(row_labels): | |
| row_options = areas[index].get("options") if index < len(areas) else None | |
| current_display_options = display_options | |
| current_display_to_value = display_to_value | |
| if isinstance(row_options, list) and row_options: | |
| current_display_options = [] | |
| current_display_to_value = {} | |
| for row_option_index, row_option in enumerate(row_options, 1): | |
| display, _ = option_display_and_value(row_option, row_option_index) | |
| matched_value = None | |
| row_text = re.sub(r"\s+", " ", str(row_option).strip()).lower() | |
| for global_display, global_value in display_to_value.items(): | |
| global_text = re.sub(r"\s+", " ", str(global_display).strip()).lower() | |
| if row_text == global_text or row_text in global_text or global_text in row_text: | |
| matched_value = global_value | |
| break | |
| current_display_options.append(display) | |
| current_display_to_value[display] = matched_value or str(row_option_index) | |
| selected = st.selectbox( | |
| row_label, | |
| ["μ ν μ ν¨"] + current_display_options, | |
| key=f"{key_prefix}_row_choice_{question['id']}_{index}", | |
| ) | |
| if selected != "μ ν μ ν¨": | |
| selections.append(current_display_to_value[selected]) | |
| return ",".join(selections) if len(selections) == expected_count else None | |
| if is_multi_answer(question): | |
| st.markdown("#### μ λ΅ μ ν") | |
| values = [] | |
| for index, display in enumerate(display_options, 1): | |
| checked = st.checkbox( | |
| display, | |
| key=f"{key_prefix}_multi_choice_{question['id']}_{index}", | |
| ) | |
| if checked: | |
| values.append(display_to_value[display]) | |
| return ",".join(values) if values else None | |
| selected = st.radio( | |
| "μ λ΅ μ ν", | |
| display_options, | |
| index=None, | |
| label_visibility="collapsed", | |
| key=f"{key_prefix}_choice_{question['id']}", | |
| ) | |
| return display_to_value.get(selected) if selected else None | |