Spaces:
Running
Running
| import os | |
| import re | |
| import json | |
| import random | |
| from datetime import datetime, date, timedelta | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| import streamlit as st | |
| import streamlit.components.v1 as components | |
| from sqlalchemy import func | |
| from cert_study_app.config import DEFAULT_USER, ensure_runtime_dirs | |
| from cert_study_app.db import SessionLocal, init_db | |
| from cert_study_app.models import Attempt, Question | |
| from cert_study_app.services.airflow_service import AirflowService, AirflowTriggerError | |
| from cert_study_app.services.concept_note_service import ConceptNoteService | |
| from cert_study_app.services.demo_seed_service import seed_demo_questions_if_empty, seed_concept_questions | |
| from cert_study_app.services.docs_source_service import active_docs_sources, doc_source_by_id, docs_source_options | |
| from cert_study_app.services.ingestion_job_service import IngestionJobService | |
| from cert_study_app.services.official_docs_service import OfficialDocsService | |
| from cert_study_app.services.learning_lab_service import ( | |
| PRACTICE_TASKS, | |
| active_tracks, | |
| certification_for_track, | |
| certifications_for_track, | |
| evaluate_lab_quiz, | |
| evaluate_lab_quiz_detail, | |
| evaluate_practice, | |
| evaluate_practice_detail, | |
| lessons_for_track, | |
| normalize_track_id, | |
| quizzes_for_track, | |
| roadmap_for_track, | |
| track_by_id, | |
| track_progress, | |
| ) | |
| from cert_study_app.services.learning_progress_service import ( | |
| completed_steps, | |
| lab_spaced_review_count, | |
| lab_spaced_review_due_today, | |
| load_completed_items, | |
| load_wrong_notes, | |
| mark_learning_step, | |
| next_day_recommendation, | |
| preferred_track, | |
| record_activity, | |
| save_completed_items, | |
| save_preferred_track, | |
| save_wrong_notes, | |
| spaced_review_count, | |
| spaced_review_due_today, | |
| streak_days, | |
| study_units, | |
| update_lab_spaced_review, | |
| update_spaced_review, | |
| weekly_summary, | |
| ) | |
| from cert_study_app.services.parse_quality_service import default_quality_report_path | |
| from cert_study_app.services.question_type_metadata_service import ( | |
| automation_summary, | |
| normalize_question_type, | |
| status_label, | |
| type_metadata, | |
| ) | |
| from cert_study_app.services.question_concept_service import ( | |
| CATEGORY_LABELS, | |
| SUBCATEGORY_LABELS, | |
| classify_question_batch, | |
| concept_label, | |
| ) | |
| from cert_study_app.services.quiz_service import QuizService, yes_no_labels | |
| from cert_study_app.services.study_assistant_service import StudyAssistantService | |
| from cert_study_app.services.vector_service import QuestionVectorStore | |
| st.set_page_config(page_title="Cert Study Lab", page_icon=":books:", layout="wide") | |
| DEFAULT_VISUAL_MODEL = os.getenv("OLLAMA_VISUAL_MODEL", "qwen3-vl:8b-instruct-q4_K_M") | |
| DEFAULT_MAIN_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:14b") | |
| DEFAULT_FAST_MODEL = os.getenv("OLLAMA_FAST_MODEL", "qwen3.5:9b") | |
| DEFAULT_DEEP_MODEL = os.getenv("OLLAMA_DEEP_MODEL", "").strip() | |
| DEFAULT_EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3") | |
| EMBEDDING_MODEL_OPTIONS = [ | |
| "BAAI/bge-m3", | |
| "intfloat/multilingual-e5-large", | |
| "sentence-transformers/paraphrase-multilingual-mpnet-base-v2", | |
| "sentence-transformers/all-MiniLM-L6-v2", | |
| ] | |
| def inject_pwa_assets(): | |
| components.html( | |
| """ | |
| <script> | |
| (function () { | |
| const doc = window.parent.document; | |
| function ensureLink(rel, href, attrs) { | |
| if (doc.querySelector(`link[rel="${rel}"][href="${href}"]`)) { | |
| return; | |
| } | |
| const link = doc.createElement("link"); | |
| link.rel = rel; | |
| link.href = href; | |
| Object.entries(attrs || {}).forEach(([key, value]) => link.setAttribute(key, value)); | |
| doc.head.appendChild(link); | |
| } | |
| function ensureMeta(name, content) { | |
| let meta = doc.querySelector(`meta[name="${name}"]`); | |
| if (!meta) { | |
| meta = doc.createElement("meta"); | |
| meta.name = name; | |
| doc.head.appendChild(meta); | |
| } | |
| meta.content = content; | |
| } | |
| ensureLink("manifest", "/app/static/manifest.webmanifest"); | |
| ensureLink("icon", "/app/static/icons/icon-192.png", { type: "image/png", sizes: "192x192" }); | |
| ensureLink("apple-touch-icon", "/app/static/icons/icon-192.png", { sizes: "192x192" }); | |
| ensureMeta("theme-color", "#2563eb"); | |
| ensureMeta("apple-mobile-web-app-capable", "yes"); | |
| ensureMeta("apple-mobile-web-app-title", "Cert Study"); | |
| ensureMeta("mobile-web-app-capable", "yes"); | |
| if ("serviceWorker" in window.parent.navigator) { | |
| window.parent.navigator.serviceWorker | |
| .register("/app/static/service-worker.js") | |
| .catch(function () {}); | |
| } | |
| })(); | |
| </script> | |
| """, | |
| height=0, | |
| width=0, | |
| ) | |
| def get_service(): | |
| db = SessionLocal() | |
| return db, QuizService(db) | |
| def init_state(): | |
| st.session_state.setdefault("question_id", None) | |
| st.session_state.setdefault("selected", None) | |
| st.session_state.setdefault("last_result", None) | |
| st.session_state.setdefault("exam_source", None) | |
| st.session_state.setdefault("page", "ํ") | |
| st.session_state.setdefault("weak_type", None) | |
| st.session_state.setdefault("similar_type", None) | |
| st.session_state.setdefault("review_question_id", None) | |
| st.session_state.setdefault("quiz_order_mode", "์์๋๋ก") | |
| st.session_state.setdefault("lab_track", normalize_track_id(preferred_track())) | |
| st.session_state.setdefault("lab_lesson_index", 0) | |
| st.session_state.setdefault("lab_quiz_index", 0) | |
| st.session_state.setdefault("lab_practice_index", 0) | |
| if "lab_completed_lessons" not in st.session_state: | |
| lessons, quizzes, practices = load_completed_items() | |
| st.session_state.lab_completed_lessons = lessons | |
| st.session_state.lab_completed_quizzes = quizzes | |
| st.session_state.lab_completed_practices = practices | |
| if "lab_wrong_notes" not in st.session_state: | |
| st.session_state.lab_wrong_notes = load_wrong_notes() | |
| st.session_state.setdefault("quiz_skill_category", "์ ์ฒด") | |
| st.session_state.setdefault("quiz_skill_subcategory", "์ ์ฒด") | |
| st.session_state.setdefault("lab_lesson_just_completed", None) | |
| def apply_mobile_styles(): | |
| st.markdown( | |
| """ | |
| <style> | |
| :root { | |
| --cert-primary: #2563eb; | |
| --cert-border: rgba(15, 23, 42, 0.12); | |
| --cert-soft: rgba(37, 99, 235, 0.08); | |
| } | |
| .block-container { | |
| padding-top: 0.75rem; | |
| padding-left: 1rem; | |
| padding-right: 1rem; | |
| max-width: 760px; | |
| } | |
| div[data-testid="stHorizontalBlock"] { | |
| gap: 0.5rem; | |
| } | |
| div[data-testid="stButton"] > button { | |
| min-height: 44px; | |
| border-radius: 8px; | |
| white-space: normal; | |
| line-height: 1.25; | |
| } | |
| div[data-testid="stRadio"] label, | |
| div[data-testid="stCheckbox"] label { | |
| min-height: 40px; | |
| align-items: flex-start; | |
| } | |
| div[role="radiogroup"] > label { | |
| border: 1px solid var(--cert-border); | |
| border-radius: 8px; | |
| padding: 0.55rem 0.7rem; | |
| margin-bottom: 0.35rem; | |
| } | |
| div[role="radiogroup"] > label:has(input:checked) { | |
| border-color: var(--cert-primary); | |
| background: var(--cert-soft); | |
| } | |
| .cert-quick-actions { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 0.5rem; | |
| margin: 0.35rem 0 0.75rem; | |
| } | |
| .cert-section-title { | |
| margin: 1.2rem 0 0.35rem; | |
| font-size: 0.95rem; | |
| font-weight: 700; | |
| color: rgba(15, 23, 42, 0.72); | |
| } | |
| div[role="radiogroup"] { | |
| gap: 0.35rem; | |
| } | |
| .answer-explanation { | |
| border: 1px solid rgba(49, 51, 63, 0.16); | |
| border-radius: 8px; | |
| padding: 0.9rem 1rem; | |
| margin-top: 0.75rem; | |
| background: rgba(250, 250, 250, 0.75); | |
| } | |
| .answer-explanation p { | |
| margin: 0.35rem 0; | |
| line-height: 1.65; | |
| } | |
| .answer-explanation ul { | |
| margin-top: 0.25rem; | |
| padding-left: 1.2rem; | |
| } | |
| .answer-explanation strong { | |
| display: block; | |
| margin-top: 0.8rem; | |
| } | |
| @media (max-width: 640px) { | |
| .block-container { | |
| padding-left: 0.7rem; | |
| padding-right: 0.7rem; | |
| } | |
| h1 { | |
| font-size: 1.6rem; | |
| } | |
| h2, h3 { | |
| font-size: 1.15rem; | |
| } | |
| .stRadio label, .stSelectbox label, .stTextArea label, .stTextInput label { | |
| font-size: 0.95rem; | |
| } | |
| div[data-testid="stMetric"] { | |
| padding: 0.25rem 0; | |
| } | |
| div[data-testid="stHorizontalBlock"] { | |
| flex-wrap: wrap; | |
| } | |
| div[data-testid="column"] { | |
| min-width: 100%; | |
| } | |
| div[data-testid="column"] div[data-testid="stMetric"] { | |
| border-bottom: 1px solid var(--cert-border); | |
| } | |
| } | |
| /* โโ Primary button enhancement โโโโโโโโโโโโโโโโโโโโโโโโโโโ */ | |
| div[data-testid="stBaseButton-primary"] > button, | |
| button[data-testid="stBaseButton-primary"] { | |
| font-size: 1rem !important; | |
| font-weight: 700 !important; | |
| letter-spacing: 0.01em !important; | |
| min-height: 52px !important; | |
| } | |
| /* โโ Home Hero โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ | |
| .cert-hero { | |
| background: linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%); | |
| border-radius: 14px 14px 0 0; | |
| padding: 1.3rem 1.3rem 1rem; | |
| margin-bottom: 0; | |
| color: #fff; | |
| } | |
| /* pull next element-container flush so button attaches */ | |
| div[data-testid="element-container"]:has(.cert-hero) { | |
| margin-bottom: 0 !important; | |
| padding-bottom: 0 !important; | |
| } | |
| div[data-testid="element-container"]:has(.cert-hero) | |
| + div[data-testid="element-container"] | |
| button[data-testid="stBaseButton-primary"] { | |
| border-top-left-radius: 0 !important; | |
| border-top-right-radius: 0 !important; | |
| margin-top: 0 !important; | |
| } | |
| .cert-hero-track { | |
| font-size: 0.78rem; | |
| opacity: 0.75; | |
| margin-bottom: 0.2rem; | |
| letter-spacing: 0.02em; | |
| } | |
| .cert-hero-streak { | |
| font-size: 1.45rem; | |
| font-weight: 800; | |
| margin-bottom: 0.85rem; | |
| letter-spacing: -0.02em; | |
| } | |
| .cert-hero-bar-wrap { | |
| background: rgba(255,255,255,0.22); | |
| border-radius: 6px; | |
| height: 9px; | |
| overflow: hidden; | |
| margin-bottom: 0.4rem; | |
| } | |
| .cert-hero-bar-fill { | |
| height: 100%; | |
| border-radius: 6px; | |
| background: #93c5fd; | |
| transition: width 0.4s ease; | |
| } | |
| .cert-hero-bar-label { | |
| font-size: 0.8rem; | |
| opacity: 0.85; | |
| } | |
| /* โโ Home Stats โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ | |
| .cert-stats-row { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 0.5rem; | |
| margin: 0.55rem 0 0.7rem; | |
| } | |
| .cert-stat-card { | |
| background: rgba(37, 99, 235, 0.05); | |
| border: 1px solid rgba(37, 99, 235, 0.14); | |
| border-radius: 10px; | |
| padding: 0.7rem 0.85rem; | |
| text-align: center; | |
| } | |
| .cert-stat-card.alert { | |
| background: rgba(239, 68, 68, 0.05); | |
| border-color: rgba(239, 68, 68, 0.22); | |
| } | |
| .cert-stat-value { | |
| font-size: 1.45rem; | |
| font-weight: 800; | |
| color: #2563eb; | |
| line-height: 1.1; | |
| } | |
| .cert-stat-card.alert .cert-stat-value { color: #dc2626; } | |
| .cert-stat-label { | |
| font-size: 0.75rem; | |
| color: rgba(15, 23, 42, 0.55); | |
| margin-top: 0.15rem; | |
| } | |
| /* โโ Section title โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ | |
| .cert-section-title { | |
| margin: 1.1rem 0 0.4rem; | |
| font-size: 0.88rem; | |
| font-weight: 700; | |
| color: rgba(15, 23, 42, 0.5); | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| } | |
| /* โโ Mode status chips โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ | |
| .cert-chip { | |
| display: inline-block; | |
| padding: 0.1rem 0.55rem; | |
| border-radius: 99px; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| vertical-align: middle; | |
| margin-left: 0.35rem; | |
| } | |
| .cert-chip-done { background: rgba(34,197,94,0.12); color: #15803d; } | |
| .cert-chip-active { background: rgba(37,99,235,0.1); color: #1d4ed8; } | |
| .cert-chip-alert { background: rgba(239,68,68,0.1); color: #dc2626; } | |
| .cert-chip-dim { background: rgba(15,23,42,0.06); color: rgba(15,23,42,0.45); } | |
| /* โโ Today Steps โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */ | |
| .cert-steps-row { | |
| display: flex; | |
| gap: 0.3rem; | |
| margin: 0.55rem 0 0.75rem; | |
| } | |
| .cert-step { | |
| flex: 1; | |
| text-align: center; | |
| padding: 0.45rem 0.2rem 0.35rem; | |
| border-radius: 8px; | |
| font-size: 0.72rem; | |
| font-weight: 600; | |
| line-height: 1.4; | |
| } | |
| .cert-step-done { background: rgba(34,197,94,0.1); color: #15803d; } | |
| .cert-step-next { background: rgba(37,99,235,0.1); color: #1d4ed8; | |
| border: 1.5px solid rgba(37,99,235,0.25); } | |
| .cert-step-pending { background: rgba(15,23,42,0.04); color: rgba(15,23,42,0.38); } | |
| /* โโ Mode cards (clickable full row) โโโโโโโโโโโโโโโโโโโโโโ */ | |
| .cert-mode-card { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 0.7rem 0.85rem; | |
| margin-bottom: 0.4rem; | |
| border: 1px solid var(--cert-border); | |
| border-radius: 10px; | |
| cursor: pointer; | |
| background: #fff; | |
| } | |
| .cert-mode-card:active { background: rgba(37,99,235,0.04); } | |
| .cert-mode-left { flex: 1; } | |
| .cert-mode-title { font-size: 0.92rem; font-weight: 700; } | |
| .cert-mode-desc { font-size: 0.76rem; color: rgba(15,23,42,0.5); margin-top: 0.1rem; } | |
| .cert-mode-arrow { font-size: 1.1rem; color: rgba(15,23,42,0.3); padding-left: 0.6rem; } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def slugify(value: str) -> str: | |
| slug = re.sub(r"[^0-9A-Za-z๊ฐ-ํฃ._-]+", "_", value.strip()) | |
| return slug.strip("._-") or "exam" | |
| def get_exams(): | |
| db, service = get_service() | |
| try: | |
| return service.list_exams() | |
| finally: | |
| db.close() | |
| def render_exam_selector(exams): | |
| sources = [exam["source"] for exam in exams] | |
| labels = ["์ ์ฒด ๋ฌธ์ "] + [ | |
| f"{exam['name']} ({exam['count']}๋ฌธํญ)" for exam in exams | |
| ] | |
| current = st.session_state.exam_source | |
| index = sources.index(current) + 1 if current in sources else 0 | |
| selected_label = st.selectbox("์ํ", labels, index=index) | |
| selected_source = None if selected_label == "์ ์ฒด ๋ฌธ์ " else sources[labels.index(selected_label) - 1] | |
| if selected_source != st.session_state.exam_source: | |
| st.session_state.exam_source = selected_source | |
| st.session_state.question_id = None | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| selected_exam = next((exam for exam in exams if exam["source"] == selected_source), None) | |
| return selected_exam, selected_source | |
| def go_to(page: str): | |
| st.session_state.page = page | |
| st.rerun() | |
| def render_top_bar(): | |
| title_col, menu_col = st.columns([0.74, 0.26], vertical_alignment="center") | |
| title_col.title("Cert Study Lab") | |
| with menu_col.popover("๋ฉ๋ด", use_container_width=True): | |
| st.caption("ํ์ต ๋ชจ๋") | |
| menu_items = [ | |
| ("๐ ๊ฐ๋ ๊ณต๋ถ", "๊ฐ๋ ๊ณต๋ถ"), | |
| ("๐ฅ ์ค์ต", "์ค์ต"), | |
| ("๐ ์ํ ์ค๋น", "์ํ์ค๋น"), | |
| ("์ค๋ต๋ ธํธ", "์ค๋ต๋ ธํธ"), | |
| ("ํ์ต ํํฉ", "๋์๋ณด๋"), | |
| ] | |
| for label, page in menu_items: | |
| if st.button(label, use_container_width=True, key=f"menu_study_{page}"): | |
| go_to(page) | |
| st.divider() | |
| st.caption("๊ด๋ฆฌ") | |
| admin_items = [ | |
| ("์ฝํ ์ธ ๊ด๋ฆฌ", "์ฝํ ์ธ ๊ด๋ฆฌ"), | |
| ("PDF ์ ๋ก๋", "PDF ์ ๋ก๋"), | |
| ("์ฒ๋ฆฌ ํํฉ", "์ฒ๋ฆฌ ํํฉ"), | |
| ("์ํ ํํฉ", "์ํ ํํฉ"), | |
| ("AI ์์ธ", "AI ์์ธ"), | |
| ] | |
| for label, page in admin_items: | |
| if st.button(label, use_container_width=True, key=f"menu_admin_{page}"): | |
| go_to(page) | |
| def track_for_question_source(source): | |
| normalized = (source or "").strip().lower() | |
| if normalized.startswith("az-104") or "azure" in normalized: | |
| return "azure" | |
| if "linux" in normalized or "lfcs" in normalized: | |
| return "linux" | |
| return normalize_track_id(st.session_state.get("lab_track", "linux")) | |
| def render_home(exams): | |
| # โโ Track ์ค์์ฒ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| tracks = active_tracks() | |
| track_ids = [t["id"] for t in tracks] | |
| track_labels = [t["name"] for t in tracks] | |
| current_track_id = normalize_track_id(st.session_state.get("lab_track", preferred_track())) | |
| current_idx = track_ids.index(current_track_id) if current_track_id in track_ids else 0 | |
| selected_label = st.radio("", track_labels, index=current_idx, horizontal=True) | |
| track_id = track_ids[track_labels.index(selected_label)] | |
| if track_id != st.session_state.get("lab_track"): | |
| save_preferred_track(track_id) | |
| st.session_state.lab_track = track_id | |
| # โโ ๋ฐ์ดํฐ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| certification = certification_for_track(track_id) | |
| session_done = completed_steps(track_id) | |
| streak = streak_days() | |
| units = study_units() | |
| due_count = spaced_review_count() | |
| _, _, apply_action_label, apply_target = focus_apply_step(track_id) | |
| # 3๋ชจ๋ ์ง๋ | |
| concept_done = {"lesson", "quiz"} <= session_done | |
| practice_done = "apply" in session_done | |
| review_done = "review" in session_done | |
| done_count = sum([concept_done, practice_done, review_done]) | |
| all_done = done_count == 3 | |
| # Smart CTA: ๊ฐ์ฅ ์์ ์ ๋ ๋จ๊ณ๋ก ์ง์ ์๋ด | |
| if not concept_done: | |
| if "lesson" not in session_done: | |
| next_label = "์ด๋ก ์นด๋ ์์ํ๊ธฐ โ" | |
| next_page = "์ด๋ก ํ์ต" | |
| else: | |
| next_label = "ํ์ธ ํด์ฆ ์ด์ด๊ฐ๊ธฐ โ" | |
| next_page = "ํ์ธ ํด์ฆ" | |
| elif not practice_done: | |
| next_label = f"{apply_action_label} โ" | |
| next_page = "์ค์ต" if track_id != "azure" else "์ํ์ค๋น" | |
| elif not review_done: | |
| next_label = "์ค๋ต ๋ณต์ตํ๊ธฐ โ" | |
| next_page = "์ค๋ต๋ ธํธ" | |
| else: | |
| next_label = "์ํ ๋ฌธ์ ๋ ํ๊ธฐ โ" | |
| next_page = "์ํ์ค๋น" | |
| # โโ ์๋ฃ ์ถํ (์ค๋ ์ฒ์ ์๋ฃ ์ ํ ๋ฒ๋ง) โโโโโโโโโโโโโโโโโโโโ | |
| celebrate_key = f"_celebrated_{track_id}_{date.today().isoformat()}" | |
| if all_done and not st.session_state.get(celebrate_key): | |
| st.session_state[celebrate_key] = True | |
| st.balloons() | |
| # โโ Hero ์นด๋ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| streak_text = f"๐ฅ {streak}์ผ ์ฐ์ ํ์ต ์ค" if streak > 0 else "์ค๋ ์ฒซ ํ์ต์ ์์ํด๋ณด์ธ์" | |
| step_label = "์ค๋ ๋ชฉํ ๋ฌ์ฑ! ๐" if all_done else f"์ค๋ {done_count}/3 ๋จ๊ณ ์๋ฃ" | |
| bar_pct = done_count / 3 * 100 | |
| cert_name = certification.get("name", "") | |
| st.markdown( | |
| f""" | |
| <div class="cert-hero"> | |
| <div class="cert-hero-track">{cert_name} ๋๋น</div> | |
| <div class="cert-hero-streak">{streak_text}</div> | |
| <div class="cert-hero-bar-wrap"> | |
| <div class="cert-hero-bar-fill" style="width:{bar_pct:.0f}%"></div> | |
| </div> | |
| <div class="cert-hero-bar-label">{step_label}</div> | |
| </div> | |
| <script> | |
| (function() {{ | |
| function connect() {{ | |
| try {{ | |
| var doc = window.parent.document; | |
| var heroes = doc.querySelectorAll('.cert-hero'); | |
| heroes.forEach(function(hero) {{ | |
| hero.style.borderBottomLeftRadius = '0'; | |
| hero.style.borderBottomRightRadius = '0'; | |
| hero.style.marginBottom = '0'; | |
| var el = hero; | |
| while (el && el.getAttribute && el.getAttribute('data-testid') !== 'element-container') el = el.parentElement; | |
| if (!el) return; | |
| var sib = el.nextElementSibling; | |
| for (var i = 0; i < 4 && sib; i++) {{ | |
| var btn = sib.querySelector('button[data-testid="stBaseButton-primary"]'); | |
| if (btn) {{ | |
| btn.style.borderTopLeftRadius = '0'; | |
| btn.style.borderTopRightRadius = '0'; | |
| break; | |
| }} | |
| sib = sib.nextElementSibling; | |
| }} | |
| }}); | |
| }} catch(e) {{}} | |
| }} | |
| setTimeout(connect, 120); | |
| setTimeout(connect, 600); | |
| }})(); | |
| </script> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # โโ ์ค๋ 3๋จ๊ณ ์ง๋ ํ์ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def _step_cls(done): return "cert-step-done" if done else ("cert-step-next" if True else "cert-step-pending") | |
| step1_cls = "cert-step-done" if concept_done else "cert-step-next" | |
| step2_cls = "cert-step-done" if practice_done else ("cert-step-next" if concept_done else "cert-step-pending") | |
| step3_cls = "cert-step-done" if review_done else ("cert-step-next" if practice_done else "cert-step-pending") | |
| st.markdown( | |
| f""" | |
| <div class="cert-steps-row"> | |
| <div class="cert-step {step1_cls}">{"โ" if concept_done else "โ "}<br>๊ฐ๋ </div> | |
| <div class="cert-step {step2_cls}">{"โ" if practice_done else "โก"}<br>์ค์ต</div> | |
| <div class="cert-step {step3_cls}">{"โ" if review_done else "โข"}<br>๋ณต์ต</div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| if all_done: | |
| if st.button("๐ ์ค๋ ์๋ฃ! ์ถ๊ฐ ๋ฌธ์ ๋ ํ๊ธฐ", type="primary", use_container_width=True): | |
| go_to("์๊ฒฉ์ฆ ๋ฌธ์ ") | |
| else: | |
| if st.button(next_label, type="primary", use_container_width=True): | |
| if next_page == apply_target and track_id == "azure": | |
| st.session_state.exam_source = "AZ-104" | |
| go_to(next_page) | |
| # โโ ํต๊ณ ์นด๋ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| due_class = "cert-stat-card alert" if due_count > 0 else "cert-stat-card" | |
| due_value = str(due_count) if due_count > 0 else "โ" | |
| st.markdown( | |
| f""" | |
| <div class="cert-stats-row"> | |
| <div class="{due_class}"> | |
| <div class="cert-stat-value">{due_value}</div> | |
| <div class="cert-stat-label">๊ฐ๊ฒฉ ๋ณต์ต ๋๊ธฐ</div> | |
| </div> | |
| <div class="cert-stat-card"> | |
| <div class="cert-stat-value">{units:.1f}</div> | |
| <div class="cert-stat-label">์ค๋ ํ๋ ๋จ์</div> | |
| </div> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| if due_count > 0: | |
| due_ids = spaced_review_due_today(limit=1) | |
| if due_ids: | |
| if st.button(f"๊ฐ๊ฒฉ ๋ณต์ต ์์ ({due_count}๋ฌธ์ ๋๊ธฐ)", use_container_width=True): | |
| st.session_state.question_id = due_ids[0] | |
| st.session_state.exam_source = None | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| go_to("์๊ฒฉ์ฆ ๋ฌธ์ ") | |
| # โโ 3 ๋ชจ๋ ์นด๋ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| st.markdown('<div class="cert-section-title">ํ์ต ๋ชจ๋</div>', unsafe_allow_html=True) | |
| # 1. ๊ฐ๋ ๊ณต๋ถ | |
| lessons = lessons_for_track(track_id) | |
| quizzes_list = quizzes_for_track(track_id) | |
| _cl = st.session_state.lab_completed_lessons | |
| _cq = st.session_state.lab_completed_quizzes | |
| done_lessons = len([l for l in lessons if l.id in _cl]) | |
| done_quizzes = len([q for q in quizzes_list if q.id in _cq]) | |
| c_chip_txt = "โ ์๋ฃ" if concept_done else "ํ์ต ์ค" | |
| c_desc = f"์ด๋ก {done_lessons}/{len(lessons)} ยท ํด์ฆ {done_quizzes}/{len(quizzes_list)}" | |
| concept_target = "๊ฐ๋ ๊ณต๋ถ" if concept_done else ("์ด๋ก ํ์ต" if "lesson" not in session_done else "ํ์ธ ํด์ฆ") | |
| if st.button( | |
| f"๐ ๊ฐ๋ ๊ณต๋ถ ยท {c_desc} ยท {c_chip_txt} โ", | |
| key="home_concept", | |
| use_container_width=True, | |
| type="secondary", | |
| ): | |
| go_to(concept_target) | |
| # 2. ์ค์ต / ๋ฌธ์ ์ ์ฉ | |
| practices = [t for t in PRACTICE_TASKS if t.track == track_id and t.status == "approved"] | |
| _cp = st.session_state.lab_completed_practices | |
| done_practices = len([t for t in practices if t.id in _cp]) | |
| if track_id == "azure": | |
| p_title, p_page = "๐ ๋ฌธ์ ์ ์ฉ", "์ํ์ค๋น" | |
| p_desc = "AZ-104 ์ค์ ๋ฌธ์ ์ ์ฉ" | |
| p_chip_txt = "โ ์๋ฃ" if practice_done else "ํ์ต ์ค" | |
| elif practices: | |
| p_title, p_page = "๐ฅ ์ค์ต", "์ค์ต" | |
| p_desc = f"์ค์ต {done_practices}/{len(practices)}" | |
| p_chip_txt = "โ ์๋ฃ" if practice_done else "ํ์ต ์ค" | |
| else: | |
| p_title, p_desc, p_page = "๐ฅ ์ค์ต", "์ด Track์ ์ค์ต ๊ณผ์ ๋ฅผ ์ค๋น ์ค์ ๋๋ค", "์ค์ต" | |
| p_chip_txt = "์ค๋น ์ค" | |
| if st.button( | |
| f"{p_title} ยท {p_desc} ยท {p_chip_txt} โ", | |
| key="home_practice", | |
| use_container_width=True, | |
| type="secondary", | |
| ): | |
| if p_page == "์ํ์ค๋น" and track_id == "azure": | |
| st.session_state.exam_source = "AZ-104" | |
| go_to(p_page) | |
| # 3. ์ํ ์ค๋น | |
| total_questions = sum(exam["count"] for exam in exams) | |
| if track_id == "azure" and total_questions > 0: | |
| e_desc = f"AZ-104 ๋คํ {total_questions}๋ฌธ์ ยท ๊ฐ๊ฒฉ ๋ฐ๋ณต" | |
| e_chip_txt = f"๋ณต์ต {due_count}๊ฐ ๋๊ธฐ" if due_count > 0 else "์ค๋น๋จ" | |
| else: | |
| e_desc = "๋คํ ๋ฌธ์ ํ์ด ยท AZ-104๋ง ํ์ฌ ์ง์" | |
| e_chip_txt = f"๋ณต์ต {due_count}๊ฐ ๋๊ธฐ" if due_count > 0 else "์ค๋น ์ค" | |
| if st.button( | |
| f"๐ ์ํ ์ค๋น ยท {e_desc} ยท {e_chip_txt} โ", | |
| key="home_exam", | |
| use_container_width=True, | |
| type="secondary", | |
| ): | |
| go_to("์ํ์ค๋น") | |
| def render_back_home(): | |
| if st.button("์ฒ์์ผ๋ก", use_container_width=True): | |
| go_to("ํ") | |
| # โโ ๋ชจ๋ ๋๋ฉ ํ์ด์ง โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def render_concept_mode_home(): | |
| """๊ฐ๋ ๊ณต๋ถ ๋๋ฉ: ์ด๋ก ์นด๋ โ ํ์ธ ํด์ฆ ์์๋ฅผ ์๋ดํ๊ณ ์ง์ ์ํจ๋ค.""" | |
| track_id = selected_lab_track() | |
| certification = certification_for_track(track_id) | |
| lessons = lessons_for_track(track_id) | |
| quizzes_list = quizzes_for_track(track_id) | |
| session_done = completed_steps(track_id) | |
| lesson_done = "lesson" in session_done | |
| quiz_done = "quiz" in session_done | |
| st.subheader("๐ ๊ฐ๋ ๊ณต๋ถ") | |
| st.caption(f"{certification.get('name', '')} ๋๋น ยท ์ด๋ก ์นด๋๋ฅผ ๋ณด๊ณ ํ์ธ ํด์ฆ๋ก ์ดํด๋๋ฅผ ์ ๊ฒํฉ๋๋ค") | |
| st.info("๐ก ์ด ์น์ ์ **์ง์ ์ ์ํ ๊ฐ๋ ํ์ต ์ฝํ ์ธ **์ ๋๋ค. ์๋ '์ํ ์ค๋น'์ ๋คํ ๋ฌธ์ ์๋ ๋ณ๊ฐ์ ๋๋ค. ๊ฐ๋ ์ ์ดํดํ ๋ค ์ํ ์ค๋น๋ก ๋์ด๊ฐ๋ฉด ํจ๊ณผ์ ์ ๋๋ค.", icon=None) | |
| with st.container(border=True): | |
| done_chip = "<span class='cert-chip cert-chip-done'>โ ์๋ฃ</span>" if lesson_done else "" | |
| st.markdown(f"**์ด๋ก ์นด๋** {done_chip}", unsafe_allow_html=True) | |
| st.caption(f"{len(lessons)}๊ฐ ์นด๋ ยท ํต์ฌ ๊ฐ๋ ์ ์ ๋ฆฌํฉ๋๋ค. ๋ชจ๋ฅด๋ ๊ฒ ์์ผ๋ฉด ๋ค์ ์นด๋๋ก ์ด์ด๊ฐ๋๋ค.") | |
| btn_label = "์ด์ด์ ๋ณด๊ธฐ" if lesson_done else "์์ํ๊ธฐ" | |
| btn_type = "secondary" if lesson_done else "primary" | |
| if st.button(btn_label, type=btn_type, use_container_width=True, key="concept_lesson_btn"): | |
| go_to("์ด๋ก ํ์ต") | |
| with st.container(border=True): | |
| done_chip = "<span class='cert-chip cert-chip-done'>โ ์๋ฃ</span>" if quiz_done else "" | |
| st.markdown(f"**ํ์ธ ํด์ฆ** {done_chip}", unsafe_allow_html=True) | |
| st.caption(f"{len(quizzes_list)}๋ฌธ์ ยท ๋ฐฉ๊ธ ๋ณธ ๊ฐ๋ ์ ์งง์ ํด์ฆ๋ก ์ ๊ฒํฉ๋๋ค.") | |
| btn_label = "๋ค์ ํ๊ธฐ" if quiz_done else "ํด์ฆ ํ๊ธฐ" | |
| btn_type = "secondary" if quiz_done else "primary" | |
| if st.button(btn_label, type=btn_type, use_container_width=True, key="concept_quiz_btn"): | |
| go_to("ํ์ธ ํด์ฆ") | |
| if lesson_done and quiz_done: | |
| st.success("์ค๋ ๊ฐ๋ ๊ณต๋ถ๋ฅผ ๋ง์ณค์ต๋๋ค. ์ค์ต์ด๋ ์ํ ์ค๋น๋ก ์ด์ด๊ฐ ์ ์์ต๋๋ค.") | |
| def render_practice_mode_home(): | |
| """์ค์ต ๋๋ฉ: track๋ณ ์ค์ต ํํฉ๊ณผ ์ง์ ๋ฒํผ.""" | |
| track_id = selected_lab_track() | |
| certification = certification_for_track(track_id) | |
| practices = [t for t in PRACTICE_TASKS if t.track == track_id and t.status == "approved"] | |
| completed = st.session_state.lab_completed_practices | |
| done_ids = completed & {t.id for t in practices} | |
| session_done = completed_steps(track_id) | |
| apply_done = "apply" in session_done | |
| st.subheader("๐ฅ ์ค์ต") | |
| st.caption(f"{certification.get('name', '')} ๋๋น ยท ๋ช ๋ น์ด๋ ๋๊ตฌ๋ฅผ ์ง์ ์คํํด ๋ด ๋๋ค") | |
| if not practices: | |
| st.info( | |
| "์ด Track์ ์์ง ์ค์ต ๊ณผ์ ๋ฅผ ์ค๋น ์ค์ ๋๋ค. " | |
| "Linux Track์ ์ ํํ๋ฉด LFCS ๋ช ๋ น์ด ์ค์ต์ ๋ฐ๋ก ์์ํ ์ ์์ต๋๋ค." | |
| ) | |
| return | |
| progress_pct = len(done_ids) / len(practices) if practices else 0 | |
| st.progress(progress_pct, text=f"์ ์ฒด ์ง๋ {len(done_ids)}/{len(practices)} ์๋ฃ") | |
| if apply_done: | |
| st.success("์ค๋ ์ค์ต์ ์๋ฃํ์ต๋๋ค.") | |
| btn_label = "์ค์ต ์ด์ด๊ฐ๊ธฐ" if len(done_ids) > 0 else "์ค์ต ์์ํ๊ธฐ" | |
| btn_type = "secondary" if apply_done else "primary" | |
| if st.button(btn_label, type=btn_type, use_container_width=True): | |
| go_to("์ค์ตํ๊ธฐ") | |
| with st.expander("์ค์ต ๊ฐ์ด๋", expanded=False): | |
| st.write( | |
| "๋ช ๋ น์ด๋ฅผ ์ง์ ์ ๋ ฅํ๊ณ ์ฑ์ ์ ๋ฐ์ต๋๋ค. " | |
| "ํํธ๋ฅผ ๋ณด๋ฉด ํ์ต ํจ๊ณผ๊ฐ ์ค์ด๋๋ ์ต๋ํ ํผ์ ๋จผ์ ์๋ํด ๋ณด์ธ์. " | |
| "ํ๋ ค๋ ๋ฐ๋ก ๋ค์ ๋ฌธ์ ๋ก ๋์ด๊ฐ์ง ๋ง๊ณ ์ ๋ต ๋ช ๋ น์ด๋ฅผ ํ ๋ฒ ์ง์ ์ณ๋ณด๋ ๊ฑธ ๊ถ์ฅํฉ๋๋ค." | |
| ) | |
| def render_exam_prep_home(exams): | |
| """์ํ ์ค๋น ๋๋ฉ: ์ํ๋ณ ๋ฌธ์ ํ์ด + ๊ฐ๊ฒฉ ๋ณต์ต ์ง์ .""" | |
| st.subheader("๐ ์ํ ์ค๋น") | |
| st.caption("๋คํ ๋ฌธ์ ๋ก ์ค์ ๊ฐ๊ฐ์ ์ตํ๊ณ , ํ๋ฆฐ ๋ฌธ์ ๋ ๊ฐ๊ฒฉ ๋ฐ๋ณต์ผ๋ก ์์ ํ ๋ด ๊ฒ์ผ๋ก ๋ง๋ญ๋๋ค.") | |
| st.info("๐ก ์ฌ๊ธฐ๋ **์ค์ ์ํ ํ์ ๋ฌธ์ ํ์ด** ๊ณต๊ฐ์ ๋๋ค. ๊ฐ๋ ์ด ์์ง ์ต์ํ์ง ์๋ค๋ฉด '๊ฐ๋ ๊ณต๋ถ'๋ฅผ ๋จผ์ ํ์ธ์.", icon=None) | |
| due_count = spaced_review_count() | |
| # ๊ฐ๊ฒฉ ๋ณต์ต ๋ฐฐ๋ (์ฐ์ ์์ ๋์) | |
| if due_count > 0: | |
| with st.container(border=True): | |
| st.markdown(f"**๐ ๊ฐ๊ฒฉ ๋ณต์ต ยท {due_count}๋ฌธ์ ๋๊ธฐ**") | |
| st.caption("ํ๋ ธ๋ ๋ฌธ์ ๊ฐ ์ค๋ ๋ณต์ต ๊ธฐํ์ด ๋์ต๋๋ค. ๋ณต์ต๋ถํฐ ํ๋ฉด ์ฅ๊ธฐ ๊ธฐ์ต ํจ์จ์ด ๋์์ง๋๋ค.") | |
| if st.button(f"๋ณต์ต ์์ ({due_count}๋ฌธ์ )", type="primary", use_container_width=True): | |
| due_ids = spaced_review_due_today(limit=1) | |
| if due_ids: | |
| st.session_state.question_id = due_ids[0] | |
| st.session_state.exam_source = None | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| go_to("์๊ฒฉ์ฆ ๋ฌธ์ ") | |
| # ์ํ๋ณ ๋ฌธ์ ํ์ด | |
| st.markdown('<div class="cert-section-title">์ํ๋ณ ๋ฌธ์ ํ์ด</div>', unsafe_allow_html=True) | |
| az_exam = next((e for e in exams if e.get("source") == "AZ-104"), None) | |
| with st.container(border=True): | |
| tc, bc = st.columns([4, 1]) | |
| tc.markdown("**AZ-104** ยท Microsoft Azure Administrator") | |
| if az_exam: | |
| tc.caption(f"๋ฌธ์ ์ํ {az_exam['count']}๋ฌธํญ ยท ์ค๋น๋จ") | |
| else: | |
| tc.caption("์ค๋น๋จ ยท ๋ฌธ์ ์๋ฅผ ๋ถ๋ฌ์ค๋ ์ค") | |
| if bc.button("์์", key="exam_az104", use_container_width=True): | |
| st.session_state.exam_source = "AZ-104" | |
| go_to("์๊ฒฉ์ฆ ๋ฌธ์ ") | |
| with st.container(border=True): | |
| tc, bc = st.columns([4, 1]) | |
| tc.markdown("**LFCS** ยท Linux Foundation Certified Sysadmin") | |
| tc.caption("๋ฌธ์ ์ํ ์ค๋น ์ค ยท ํ์ฌ๋ ์ค์ต์ผ๋ก ๋๋น") | |
| if bc.button("์ค์ต์ผ๋ก ์ด๋", key="exam_lfcs", use_container_width=True): | |
| go_to("์ค์ต") | |
| # ๊ฐ๋ ๋ชจ์์ํ (JSON ํด์ฆ ๊ธฐ๋ฐ) | |
| st.markdown('<div class="cert-section-title">๊ฐ๋ ๋ชจ์์ํ</div>', unsafe_allow_html=True) | |
| with st.container(border=True): | |
| tc, bc = st.columns([4, 1]) | |
| tc.markdown("**๐งช ๊ฐ๋ ๋ชจ์์ํ** ยท ์ง์ ์ ์ ํด์ฆ ๊ธฐ๋ฐ") | |
| tc.caption("์ด๋ก ยทCLI ํด์ฆ๋ฅผ ๋๋ค ์ถ์ , ํ์ด๋จธ ์์ ยท ๋คํ ๋ฌธ์ ์ ๋ณ๊ฐ") | |
| if bc.button("์์", key="exam_concept_mock", use_container_width=True): | |
| go_to("์ํ ๋ชจ๋") | |
| st.markdown('<div class="cert-section-title">๋ณต์ต</div>', unsafe_allow_html=True) | |
| col1, col2 = st.columns(2) | |
| if col1.button("์ค๋ต๋ ธํธ", use_container_width=True): | |
| go_to("์ค๋ต๋ ธํธ") | |
| if col2.button("์ทจ์ฝ ๊ฐ๋ ํ์ต", use_container_width=True): | |
| go_to("์ทจ์ฝ ๊ฐ๋ ํ์ต") | |
| def render_exam_overview(exams, selected_exam): | |
| st.subheader("์ํ ํํฉ") | |
| total_questions = sum(exam["count"] for exam in exams) | |
| col1, col2, col3 = st.columns(3) | |
| col1.metric("์ํ", f"{len(exams)}๊ฐ") | |
| col2.metric("์ ์ฒด ๋ฌธํญ", f"{total_questions}๊ฐ") | |
| col3.metric("์ ํ ๋ฌธํญ", f"{selected_exam['count']}๊ฐ" if selected_exam else f"{total_questions}๊ฐ") | |
| if not exams: | |
| st.info("์์ง ๋ฑ๋ก๋ ์ํ ๋ฌธ์ ๊ฐ ์์ต๋๋ค. ์ ๋ก๋์์ ์ํ๋ช ์ ์ง์ ํ๊ณ PDF๋ฅผ ์ ์ฌํด ์ฃผ์ธ์.") | |
| return | |
| st.dataframe( | |
| [ | |
| { | |
| "์ํ": exam["name"], | |
| "๋ฌธํญ ์": exam["count"], | |
| "์ฒซ ๋ฌธํญ": exam["first_question_id"], | |
| "๋ง์ง๋ง ๋ฌธํญ": exam["last_question_id"], | |
| } | |
| for exam in exams | |
| ], | |
| hide_index=True, | |
| use_container_width=True, | |
| ) | |
| def selected_lab_track() -> str: | |
| tracks = active_tracks() | |
| labels = [] | |
| for track in tracks: | |
| certification_names = " / ".join(certification["name"] for certification in certifications_for_track(track["id"])) | |
| labels.append(f"{track['name']} ยท {certification_names or '๋ฏธ์ '}") | |
| ids = [track["id"] for track in tracks] | |
| current = normalize_track_id(st.session_state.get("lab_track", "linux")) | |
| index = ids.index(current) if current in ids else 0 | |
| selected_label = st.selectbox("Track", labels, index=index) | |
| track_id = ids[labels.index(selected_label)] | |
| if track_id != st.session_state.get("lab_track"): | |
| save_preferred_track(track_id) | |
| st.session_state.lab_track = track_id | |
| return track_id | |
| def render_spaced_review_panel(): | |
| due_count = spaced_review_count() | |
| if due_count == 0: | |
| return | |
| with st.container(border=True): | |
| st.markdown(f"#### ๊ฐ๊ฒฉ ๋ณต์ต ยท ์ค๋ {due_count}๋ฌธ์ ๋๊ธฐ") | |
| st.caption("ํ๋ฆฐ ๋ฌธ์ ๋ฅผ 1โ3โ7โ14โ30์ผ ๊ฐ๊ฒฉ์ผ๋ก ์ฌ์ถ์ ํฉ๋๋ค. 3ํ ์ฐ์ ์ ๋ต์ด๋ฉด ์์ ํ์ต์ผ๋ก ์ฒ๋ฆฌ๋ฉ๋๋ค.") | |
| due_ids = spaced_review_due_today(limit=5) | |
| for qid in due_ids: | |
| if st.button(f"๋ฌธ์ #{qid} ํ๊ธฐ", key=f"spaced_go_{qid}", use_container_width=True): | |
| st.session_state.question_id = qid | |
| st.session_state.exam_source = None | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| go_to("์๊ฒฉ์ฆ ๋ฌธ์ ") | |
| if due_count > 5: | |
| st.caption(f"โฆ์ธ {due_count - 5}๋ฌธ์ ") | |
| def render_dashboard(exams): | |
| st.subheader("๋์๋ณด๋") | |
| st.caption("์ง๋์ ์ถ์ฒ ๋ณต์ต์ ์ฌ๊ธฐ์์๋ง ํ์ธํฉ๋๋ค.") | |
| render_today_plan(exams) | |
| render_spaced_review_panel() | |
| render_weak_recommendations() | |
| def render_today_plan(exams): | |
| total_questions = sum(exam["count"] for exam in exams) | |
| track_id = selected_lab_track() | |
| track = track_by_id(track_id) | |
| certification = certification_for_track(track_id) | |
| lessons = lessons_for_track(track_id) | |
| quizzes = quizzes_for_track(track_id) | |
| practices = [task for task in PRACTICE_TASKS if task.track == track_id and task.status == "approved"] | |
| persisted_steps = completed_steps(track_id) | |
| progress = track_progress( | |
| track_id, | |
| set(st.session_state.lab_completed_lessons), | |
| set(st.session_state.lab_completed_quizzes), | |
| set(st.session_state.lab_completed_practices), | |
| ) | |
| week = weekly_summary() | |
| streak = streak_days() | |
| with st.container(border=True): | |
| st.markdown("### ์ด์ด์ ๊ณต๋ถ") | |
| st.caption(f"{track['name']} ์ค์ฌ ยท ๋ชฉํ ์๊ฒฉ์ฆ: {certification['name']}") | |
| col1, col2, col3 = st.columns(3) | |
| col1.metric("์ด๋ก ์นด๋", f"{min(3, len(lessons))}๊ฐ") | |
| col2.metric("ํ์ธ ํด์ฆ", f"{min(5, len(quizzes))}๋ฌธ์ ") | |
| if track_id == "tool_docs": | |
| third_label = "Docs ๋ณต์ต" | |
| third_value = "1๊ฐ" | |
| else: | |
| third_label = "์ค๋ต ๋ณต์ต" | |
| third_value = "1๊ฐ" | |
| col3.metric(third_label, third_value) | |
| wrong_count = len([item for item in st.session_state.lab_wrong_notes if item.get("track") == track_id]) | |
| st.caption(f"์ค๋ต ๋ณต์ต {wrong_count}๊ฐ ยท ๋ฑ๋ก๋ ์๊ฒฉ์ฆ ๋ฌธ์ {total_questions}๊ฐ") | |
| st.progress(progress["percent"] / 100 if progress["total"] else 0, text=f"{track['name']} ์งํ๋ฅ {progress['completed']}/{progress['total']}") | |
| focus_step_count = len(persisted_steps & {"lesson", "quiz", "apply", "review"}) | |
| st.progress(focus_step_count / 4, text=f"Focus ์ง๋ {focus_step_count}/4") | |
| metric1, metric2, metric3 = st.columns(3) | |
| metric1.metric("์ฐ์ ํ์ต", f"{streak}์ผ") | |
| metric2.metric("์ค๋ ํ๋", f"{study_units():.1f}๋จ์") | |
| metric3.metric("์ด๋ฒ ์ฃผ ๋์ ", f"{week['study_units']:.1f}๋จ์") | |
| st.caption(next_day_recommendation(track_id)) | |
| def render_weak_recommendations(): | |
| db = SessionLocal() | |
| try: | |
| rows = ( | |
| db.query(Question.category, Question.subcategory, func.count(Attempt.id)) | |
| .join(Question, Attempt.question_id == Question.id) | |
| .filter(Attempt.user_id == DEFAULT_USER, Attempt.note_type == "wrong") | |
| .group_by(Question.category, Question.subcategory) | |
| .order_by(func.count(Attempt.id).desc()) | |
| .limit(3) | |
| .all() | |
| ) | |
| if not rows: | |
| return | |
| with st.container(border=True): | |
| st.markdown("### ์ค๋ ์ถ์ฒ ๋ณต์ต") | |
| for category, subcategory, count in rows: | |
| st.caption(f"{concept_label(category, subcategory)} ยท ์ค๋ต {count}ํ") | |
| if st.button("์ค๋ต ์ถ์ฒ์ผ๋ก ๋ณต์ต", use_container_width=True): | |
| go_to("์ค๋ต๋ ธํธ") | |
| finally: | |
| db.close() | |
| def focus_apply_step(track_id: str) -> tuple[str, str, str, str]: | |
| if track_id == "linux": | |
| return ( | |
| "์ค์ต ์ ์ฉ", | |
| "๋ฐฉ๊ธ ๋ณธ ๊ฐ๋ ์ ๋ช ๋ น์ด ์ค์ต์ผ๋ก ๋ฐ๋ก ์จ๋ด ๋๋ค.", | |
| "์ค์ตํ๊ธฐ", | |
| "์ค์ตํ๊ธฐ", | |
| ) | |
| if track_id == "azure": | |
| return ( | |
| "๋ฌธ์ ์ ์ฉ", | |
| "๊ฐ๋ ์ AZ-104 ๋ฌธ์ ์ ๋ฐ๋ก ์ ์ฉํด ๋ด ๋๋ค.", | |
| "๋ฌธ์ ํ๊ธฐ", | |
| "์๊ฒฉ์ฆ ๋ฌธ์ ", | |
| ) | |
| return ( | |
| "๋ฌธ์ ์ ์ฉ", | |
| "๊ณต์ ๋ฌธ์ ์นด๋์ ํด์ฆ๋ฅผ ์ด์ด ๋ณด๋ฉฐ ๋๊ตฌ ๊ฐ๋ ์ ๊ตณํ๋๋ค.", | |
| "์ด๋ก ์ด์ด๋ณด๊ธฐ", | |
| "์ด๋ก ํ์ต", | |
| ) | |
| def focus_step_flow(track_id: str) -> list[tuple[str, str, str, str, str]]: | |
| lessons = lessons_for_track(track_id) | |
| quizzes = quizzes_for_track(track_id) | |
| apply_title, apply_description, apply_label, apply_target = focus_apply_step(track_id) | |
| return [ | |
| ("lesson", "๊ฐ๋ ์ดํด", f"์นด๋ {min(1, len(lessons))}๊ฐ๋ถํฐ ์์ํ๊ณ , ์ํ๋ฉด ๊ณ์ ๋ค์ ์นด๋๋ก ๋์ด๊ฐ๋๋ค.", "๊ฐ๋ ๋ณด๊ธฐ", "์ด๋ก ํ์ต"), | |
| ("quiz", "๋ฐ๋ก ํ์ธ", f"{min(3, len(quizzes))}๊ฐ๋ก ์ดํด๋๋ฅผ ์ ๊ฒํ ๋ค ๊ณ์ ํ ์ ์์ต๋๋ค.", "ํ์ธ ํด์ฆ ํ๊ธฐ", "ํ์ธ ํด์ฆ"), | |
| ("apply", apply_title, apply_description, apply_label, apply_target), | |
| ("review", "์ค๋ต ์ ๋ฆฌ", "์ค๋ต 1๊ฐ๋ถํฐ ๋ณด๊ณ , ๋ ๋ณต์ตํ๋ฉด ๋์ ํ์ต๋์ผ๋ก ๊ธฐ๋ก๋ฉ๋๋ค.", "์ค๋ต ๋ณต์ต์ผ๋ก ์ด๋", "์ค๋ต๋ ธํธ"), | |
| ] | |
| def render_focus_progress_flow(track_id: str): | |
| track = track_by_id(track_id) | |
| certification = certification_for_track(track_id) | |
| session_done = completed_steps(track_id) | |
| session_steps = focus_step_flow(track_id) | |
| _, _, _, apply_target = focus_apply_step(track_id) | |
| st.caption(f"{track['name']} Track ยท {certification['name']} ยท ๊ฐ๋ ๋ถํฐ ์ค๋ต๊น์ง ์์๋๋ก ์ด์ด๊ฐ๋๋ค.") | |
| done_count = len(session_done & {step[0] for step in session_steps}) | |
| st.progress(done_count / len(session_steps), text=f"์ง๋ ํ๋ฆ {done_count}/{len(session_steps)} ยท ์ค๋ ํ๋ {study_units():.1f}๋จ์") | |
| if st.button("๊ณ์ ์ด์ด๊ฐ๊ธฐ", type="primary", use_container_width=True): | |
| if "lesson" not in session_done: | |
| go_to("์ด๋ก ํ์ต") | |
| if "quiz" not in session_done: | |
| go_to("ํ์ธ ํด์ฆ") | |
| if "apply" not in session_done: | |
| if track_id == "azure": | |
| st.session_state.exam_source = "AZ-104" | |
| go_to(apply_target) | |
| if "review" not in session_done: | |
| go_to("์ค๋ต๋ ธํธ") | |
| go_to("์ด๋ก ํ์ต") | |
| for index, (step_id, title, description, action_label, target_page) in enumerate(session_steps, 1): | |
| with st.container(border=True): | |
| done = step_id in session_done | |
| st.markdown(f"### {index}. {'์๋ฃ ยท ' if done else ''}{title}") | |
| st.write(description) | |
| col1, col2 = st.columns([1, 1]) | |
| if col1.button(action_label, type="primary" if not done else "secondary", use_container_width=True, key=f"today_go_{step_id}"): | |
| if step_id == "apply" and track_id == "azure": | |
| st.session_state.exam_source = "AZ-104" | |
| go_to(target_page) | |
| if col2.button("์๋ฃ ์ฒดํฌ", use_container_width=True, key=f"today_done_{step_id}", disabled=done): | |
| mark_learning_step(track_id, step_id) | |
| st.rerun() | |
| if done_count == len(session_steps): | |
| st.success("์ง๋ ํ๋ฆ์ ์ง๋์์ต๋๋ค. ๋ ๊ณต๋ถํ๋ฉด ์ค๋ ํ๋๋์ ๊ณ์ ๋ํด์ง๋๋ค.") | |
| def render_continue_study(): | |
| st.subheader("Focus") | |
| track_id = selected_lab_track() | |
| render_focus_progress_flow(track_id) | |
| def render_focus_mode(): | |
| st.subheader("Focus ๊ณต๋ถ") | |
| track_id = selected_lab_track() | |
| track = track_by_id(track_id) | |
| certification = certification_for_track(track_id) | |
| st.caption(f"{track['name']} Track ยท {certification['name']} ยท ์ง๋๋ฅผ ์ด์ด๊ฐ๊ฑฐ๋, ์ง๊ธ ํ์ํ ๊ฒ๋ง ๊ณจ๋ผ ๊ณต๋ถํฉ๋๋ค.") | |
| focus_mode = st.radio("๊ณต๋ถ ๋ฐฉ์", ["์ง๋ ์ด์ด๊ฐ๊ธฐ", "์ํ๋ ๊ฒ๋ง ํ๊ธฐ"], horizontal=True) | |
| if focus_mode == "์ง๋ ์ด์ด๊ฐ๊ธฐ": | |
| render_focus_progress_flow(track_id) | |
| return | |
| focus_options = ["๊ฐ๋ ", "ํ์ธ", "์ ์ฉ", "์ค๋ต", "๋ก๋๋งต"] | |
| selected_focus = st.radio("์ง๊ธ ํ ๊ฒ", focus_options, horizontal=True) | |
| apply_target = focus_apply_step(track_id)[3] | |
| focus_targets = { | |
| "๊ฐ๋ ": "์ด๋ก ํ์ต", | |
| "ํ์ธ": "ํ์ธ ํด์ฆ", | |
| "์ ์ฉ": apply_target, | |
| "์ค๋ต": "์ค๋ต๋ ธํธ", | |
| "๋ก๋๋งต": "๋ก๋๋งต", | |
| } | |
| focus_descriptions = { | |
| "๊ฐ๋ ": "์ ๊ฐ๋ ์ ๋จผ์ ์ ๋ฆฌํฉ๋๋ค. ํท๊ฐ๋ฆฌ๋ ์ฉ์ด์ ์์ฃผ ํ๋ฆฌ๋ ํฌ์ธํธ๋ฅผ ํ์ธํ๊ธฐ ์ข์ต๋๋ค.", | |
| "ํ์ธ": "์งง์ ํด์ฆ๋ก ๋ฐฉ๊ธ ์๋์ง ๋ฐ๋ก ์ ๊ฒํฉ๋๋ค.", | |
| "์ ์ฉ": "Linux๋ ์ค์ต, Azure๋ ๋ฌธ์ ์ ์ฉ, Tool Docs๋ ๋ฌธ์ ์นด๋ ๋ณต์ต์ผ๋ก ์ฐ๊ฒฐํฉ๋๋ค.", | |
| "์ค๋ต": "ํ๋ฆฐ ๋ฌธ์ ์ ์ฝํ ๊ฐ๋ ์ ๋ค์ ๋ด ๋๋ค.", | |
| "๋ก๋๋งต": "์ง๊ธ ๊ณต๋ถํ๋ Track์ ์ ์ฒด ์์๋ฅผ ํ์ธํฉ๋๋ค.", | |
| } | |
| st.info(focus_descriptions[selected_focus]) | |
| if st.button(f"{selected_focus} ์์", type="primary", use_container_width=True): | |
| go_to(focus_targets[selected_focus]) | |
| if track_id == "tool_docs": | |
| st.info("Tool Docs๋ ๊ณต์ ๋ฌธ์ ์์ฝ ์นด๋์ ํ์ธ ํด์ฆ๋ฅผ ๋ฐ๋ณตํ๋ ๋ฐฉ์์ผ๋ก ์ด์ํฉ๋๋ค.") | |
| elif track_id == "azure": | |
| st.info("AZ-104 ๋ฌธ์ ํ์ด์ ๋ชฐ์ ํ๋ ค๋ฉด `Exam`์ ์ฌ์ฉํ์ธ์.") | |
| def render_exam_study_mode(): | |
| st.subheader("Exam") | |
| st.caption("์๊ฒฉ์ฆ ์ง์ค ๋ชจ๋์ ๋๋ค. ๋จผ์ Track๊ณผ ์ํ์ ๊ณ ๋ฅธ ๋ค ํ์ฌ ์ค๋น ์ํ์ ๋ง๊ฒ ๊ณต๋ถํฉ๋๋ค.") | |
| exam_tracks = [track for track in active_tracks() if track["id"] in {"azure", "linux"}] | |
| track_labels = [track["name"] for track in exam_tracks] | |
| current_track = normalize_track_id(st.session_state.get("lab_track", "linux")) | |
| track_index = next((index for index, track in enumerate(exam_tracks) if track["id"] == current_track), 0) | |
| selected_track_label = st.selectbox("Track", track_labels, index=track_index, key="exam_track_selector") | |
| selected_track = exam_tracks[track_labels.index(selected_track_label)] | |
| st.session_state.lab_track = selected_track["id"] | |
| save_preferred_track(selected_track["id"]) | |
| certifications = certifications_for_track(selected_track["id"]) | |
| cert_labels = [f"{cert['name']} ยท {cert['study_mode']}" for cert in certifications] | |
| selected_cert_label = st.selectbox("์๊ฒฉ์ฆ", cert_labels, key="exam_certification_selector") | |
| certification = certifications[cert_labels.index(selected_cert_label)] | |
| if certification["id"] == "az-104": | |
| st.session_state.exam_source = "AZ-104" | |
| else: | |
| st.session_state.exam_source = None | |
| readiness_messages = { | |
| "ready_with_questions": "๋ฌธ์ ์ํ์ด ์ค๋น๋์ด ์์ด ๋ฌธ์ ํ์ด์ ์ธ๋ถ๊ฐ๋ ๋ฐ๋ณต์ ๋ฐ๋ก ์ฌ์ฉํ ์ ์์ต๋๋ค.", | |
| "practice_based": "๋ฌธ์ ์ํ์ ์์ง ์์ง๋ง, ์ค์ต ๊ณผ์ ์ ๋ช ๋ น์ด ์ํ์ผ๋ก ์ํ ๋๋น๋ฅผ ์งํํฉ๋๋ค.", | |
| "concept_quiz_based": "๋ฌธ์ ์ํ์ ์์ง ์์ง๋ง, ๊ฐ๋ ์นด๋์ ํ์ธ ํด์ฆ๋ก ํ๊ธฐํ ๋๋น๋ฅผ ์์ํฉ๋๋ค.", | |
| } | |
| st.info(readiness_messages.get(certification.get("readiness"), "ํ์ต ์๋ฃ๋ฅผ ์ค๋น ์ค์ ๋๋ค.")) | |
| col1, col2 = st.columns(2) | |
| if col1.button("๋ฌธ์ ํ์ด", type="primary", use_container_width=True): | |
| if certification["id"] == "lfcs": | |
| st.toast("LFCS ๋ฌธ์ ์ํ์ ์์ง ์ค๋น ์ค์ ๋๋ค. ์ค์ตํ ํ์ต์ผ๋ก ์ฐ๊ฒฐํฉ๋๋ค.") | |
| go_to("์ค์ตํ๊ธฐ") | |
| if certification["id"] == "linux-master": | |
| st.toast("๋ฆฌ๋ ์ค๋ง์คํฐ ๋ฌธ์ ์ํ์ ์์ง ์ค๋น ์ค์ ๋๋ค. ํ์ธ ํด์ฆ๋ก ์ฐ๊ฒฐํฉ๋๋ค.") | |
| go_to("ํ์ธ ํด์ฆ") | |
| go_to("์๊ฒฉ์ฆ ๋ฌธ์ ") | |
| if col2.button("์ํ ๋ชจ๋ ์ค์ ", use_container_width=True): | |
| go_to("์ํ ๋ชจ๋") | |
| col3, col4 = st.columns(2) | |
| if col3.button("์ธ๋ถ๊ฐ๋ ๋ฐ๋ณต", use_container_width=True): | |
| if certification["id"] == "lfcs": | |
| go_to("๋ก๋๋งต") | |
| if certification["id"] == "linux-master": | |
| go_to("์ด๋ก ํ์ต") | |
| go_to("์๊ฒฉ์ฆ ๋ฌธ์ ") | |
| if col4.button("์ค๋ต ๋ณต์ต", use_container_width=True): | |
| go_to("์ค๋ต๋ ธํธ") | |
| def render_roadmap(): | |
| st.subheader("๋ก๋๋งต") | |
| track_id = selected_lab_track() | |
| track = track_by_id(track_id) | |
| certification = certification_for_track(track_id) | |
| st.caption(f"{track['name']} Track ยท {certification['name']} ๋๋น") | |
| steps = roadmap_for_track(track_id) | |
| if not steps: | |
| st.info("์์ง ์ค๋น ์ค์ธ Track์ ๋๋ค.") | |
| return | |
| for index, step in enumerate(steps, 1): | |
| with st.container(border=True): | |
| st.markdown(f"**{index}. {step.title}**") | |
| st.caption(step.level) | |
| st.write(step.description) | |
| def render_quiz_skill_filter(db, source): | |
| if source not in {None, "AZ-104"}: | |
| return None, None | |
| rows = ( | |
| db.query(Question.category, Question.subcategory, func.count(Question.id)) | |
| .filter(Question.source == "AZ-104", Question.category.isnot(None)) | |
| .group_by(Question.category, Question.subcategory) | |
| .order_by(Question.category.asc(), Question.subcategory.asc()) | |
| .all() | |
| ) | |
| if not rows: | |
| st.caption("์์ง AZ-104 ์์ญ ๋ถ๋ฅ๊ฐ ์์ต๋๋ค. ์ฒ๋ฆฌ ํํฉ์์ AZ-104 ์์ญ ๋ถ๋ฅ๋ฅผ ๋จผ์ ์คํํด ์ฃผ์ธ์.") | |
| return None, None | |
| categories = [] | |
| for category, _subcategory, _count in rows: | |
| if category and category not in categories: | |
| categories.append(category) | |
| category_labels = ["์ ์ฒด"] + [concept_label(category) for category in categories] | |
| current_category = st.session_state.get("quiz_skill_category", "์ ์ฒด") | |
| current_index = categories.index(current_category) + 1 if current_category in categories else 0 | |
| selected_category_label = st.selectbox("AZ-104 ๋๋ถ๋ฅ", category_labels, index=current_index) | |
| selected_category = None if selected_category_label == "์ ์ฒด" else categories[category_labels.index(selected_category_label) - 1] | |
| selected_subcategory = None | |
| if selected_category: | |
| sub_rows = [(subcategory, count) for category, subcategory, count in rows if category == selected_category and subcategory] | |
| subcategory_values = [subcategory for subcategory, _count in sub_rows] | |
| subcategory_labels = ["์ ์ฒด"] + [f"{concept_label(selected_category, subcategory)} ({count}๋ฌธํญ)" for subcategory, count in sub_rows] | |
| current_subcategory = st.session_state.get("quiz_skill_subcategory", "์ ์ฒด") | |
| sub_index = subcategory_values.index(current_subcategory) + 1 if current_subcategory in subcategory_values else 0 | |
| selected_subcategory_label = st.selectbox("์ธ๋ถ ๊ฐ๋ ", subcategory_labels, index=sub_index) | |
| selected_subcategory = None if selected_subcategory_label == "์ ์ฒด" else subcategory_values[subcategory_labels.index(selected_subcategory_label) - 1] | |
| selected_category_state = selected_category or "์ ์ฒด" | |
| selected_subcategory_state = selected_subcategory or "์ ์ฒด" | |
| if selected_category_state != st.session_state.get("quiz_skill_category"): | |
| st.session_state.quiz_skill_category = selected_category_state | |
| st.session_state.quiz_skill_subcategory = "์ ์ฒด" | |
| st.session_state.question_id = None | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| if selected_subcategory_state != st.session_state.get("quiz_skill_subcategory"): | |
| st.session_state.quiz_skill_subcategory = selected_subcategory_state | |
| st.session_state.question_id = None | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| return selected_category, selected_subcategory | |
| def render_theory_learning(): | |
| st.subheader("์ด๋ก ํ์ต") | |
| track_id = selected_lab_track() | |
| track = track_by_id(track_id) | |
| certification = certification_for_track(track_id) | |
| st.caption(f"{track['name']} Track ยท {certification['name']} ๋๋น") | |
| all_lessons = lessons_for_track(track_id) | |
| if not all_lessons: | |
| st.info("์์ง ์น์ธ๋ ์ด๋ก ์นด๋๊ฐ ์์ต๋๋ค.") | |
| return | |
| # โโ ๊ฒ์ / ํํฐ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| search_col, level_col, filter_col = st.columns([3, 1, 1]) | |
| search_q = search_col.text_input("๋ ์จ ๊ฒ์", placeholder="ํค์๋ ๋๋ ์ ๋ชฉ ์ ๋ ฅโฆ", label_visibility="collapsed", key="lesson_search") | |
| level_filter = level_col.selectbox("๋ ๋ฒจ", ["์ ์ฒด", "์ ๋ฌธ", "์ค๊ธ", "๊ณ ๊ธ"], key="lesson_level_filter", label_visibility="collapsed") | |
| show_incomplete = filter_col.checkbox("๋ฏธ์๋ฃ๋ง", key="lesson_incomplete_only") | |
| completed_lessons = st.session_state.lab_completed_lessons | |
| lessons = all_lessons | |
| if search_q.strip(): | |
| q = search_q.strip().lower() | |
| lessons = [l for l in lessons if q in l.title.lower() or any(q in kw.lower() for kw in l.keywords) or q in l.summary.lower()] | |
| if level_filter != "์ ์ฒด": | |
| lessons = [l for l in lessons if l.level == level_filter] | |
| if show_incomplete: | |
| lessons = [l for l in lessons if l.id not in completed_lessons] | |
| if not lessons: | |
| st.info("๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค.") | |
| return | |
| # ํํฐ ๊ฒฐ๊ณผ ๋ด์์ index ์ ์ง | |
| raw_index = st.session_state.lab_lesson_index | |
| # lesson์ ์ ์ฒด index๋ฅผ ๊ธฐ์ค์ผ๋ก ํํฐ๋ lessons์์ ํ์ฌ ์์น ์ฐพ๊ธฐ | |
| if raw_index < len(all_lessons): | |
| cur_id = all_lessons[raw_index].id | |
| filtered_ids = [l.id for l in lessons] | |
| if cur_id in filtered_ids: | |
| index = filtered_ids.index(cur_id) | |
| else: | |
| index = 0 | |
| else: | |
| index = 0 | |
| index = min(index, len(lessons) - 1) | |
| lesson = lessons[index] | |
| # ์ง๋ ํ์ | |
| done_count = len([l for l in all_lessons if l.id in completed_lessons]) | |
| st.progress(done_count / len(all_lessons), text=f"{done_count}/{len(all_lessons)} ์๋ฃ") | |
| if search_q.strip() or show_incomplete: | |
| st.caption(f"ํํฐ ๊ฒฐ๊ณผ: {len(lessons)}๊ฐ ยท {index + 1}/{len(lessons)}") | |
| else: | |
| st.caption(f"{index + 1}/{len(all_lessons)}") | |
| is_done = lesson.id in completed_lessons | |
| _level_badge = {"์ ๋ฌธ": "๐ข ์ ๋ฌธ", "์ค๊ธ": "๐ก ์ค๊ธ", "๊ณ ๊ธ": "๐ด ๊ณ ๊ธ"}.get(lesson.level, lesson.level) | |
| with st.container(border=True): | |
| title_line = f"### {'โ ' if is_done else ''}{lesson.title}" | |
| st.markdown(title_line) | |
| st.caption(_level_badge) | |
| st.markdown("**ํต์ฌ ์ดํด**") | |
| st.write(lesson.summary) | |
| if lesson.details: | |
| for detail in lesson.details: | |
| st.markdown(f"- {detail}") | |
| st.markdown("**์์**") | |
| st.code(lesson.example) | |
| st.markdown("**ํท๊ฐ๋ฆด ํฌ์ธํธ**") | |
| st.write(lesson.common_mistake) | |
| st.caption("ํค์๋: " + ", ".join(lesson.keywords)) | |
| source = doc_source_by_id(lesson.source_id) | |
| if source: | |
| st.markdown(f"์ถ์ฒ: [{source.provider} ยท {source.title}]({source.url})") | |
| if not is_done: | |
| if st.button("ํ์ต ์๋ฃ", type="primary", use_container_width=True): | |
| st.session_state.lab_completed_lessons.add(lesson.id) | |
| save_completed_items( | |
| st.session_state.lab_completed_lessons, | |
| st.session_state.lab_completed_quizzes, | |
| st.session_state.lab_completed_practices, | |
| ) | |
| mark_learning_step(track_id, "lesson") | |
| record_activity(track_id, "lesson", 1) | |
| st.session_state.lab_lesson_just_completed = lesson.id | |
| st.rerun() | |
| else: | |
| # ๋ฐฉ๊ธ ์๋ฃํ ๊ฒฝ์ฐ โ ๋ค์ ๋จ๊ณ ๋ช ํํ ์๋ด | |
| if st.session_state.get("lab_lesson_just_completed") == lesson.id: | |
| st.success(f"โ ํ์ต ์๋ฃ! ์ค๋ ํ๋ {study_units():.1f}๋จ์ ยท ์ด์ ํ์ธ ํด์ฆ๋ก ์ดํด๋๋ฅผ ์ ๊ฒํ์ธ์.") | |
| # ์ด ๋ ์จ์ ๊ด๋ จ ํด์ฆ ๋ชฉ๋ก | |
| all_quizzes = quizzes_for_track(track_id) | |
| related = [q for q in all_quizzes if q.lesson_id == lesson.id] | |
| if related: | |
| btn_label = f"์ด ๋ ์จ ํ์ธ ํด์ฆ {len(related)}๊ฐ ๋ฐ๋ก ํ๊ธฐ โ" | |
| if st.button(btn_label, type="primary", use_container_width=True): | |
| due_ids = set(lab_spaced_review_due_today()) | |
| due_q = [q for q in all_quizzes if q.id in due_ids] | |
| other_q = [q for q in all_quizzes if q.id not in due_ids] | |
| ordered = due_q + other_q | |
| related_ids = {q.id for q in related} | |
| first_idx = next((i for i, q in enumerate(ordered) if q.id in related_ids), 0) | |
| st.session_state.lab_quiz_index = first_idx | |
| st.session_state.lab_lesson_just_completed = None | |
| go_to("ํ์ธ ํด์ฆ") | |
| else: | |
| if st.button("ํ์ธ ํด์ฆ ์ ์ฒด ์ด์ด๊ฐ๊ธฐ โ", type="primary", use_container_width=True): | |
| st.session_state.lab_lesson_just_completed = None | |
| go_to("ํ์ธ ํด์ฆ") | |
| # ๊ด๋ จ ์ค์ต ๋ฐ๋ก ๊ฐ๊ธฐ | |
| if lesson.related_practices: | |
| all_tasks = [t for t in PRACTICE_TASKS if t.track == track_id and t.status == "approved"] | |
| task_ids = [t.id for t in all_tasks] | |
| related_task_ids = [pid for pid in lesson.related_practices if pid in task_ids] | |
| if related_task_ids: | |
| st.markdown("**๊ด๋ จ ์ค์ต ๋ฐ๋ก ๊ฐ๊ธฐ**") | |
| for pid in related_task_ids: | |
| task_idx = task_ids.index(pid) | |
| task_title = all_tasks[task_idx].title | |
| if st.button(f"์ค์ต: {task_title}", key=f"goto_practice_{pid}", use_container_width=True): | |
| st.session_state.lab_practice_index = task_idx | |
| st.session_state.lab_lesson_just_completed = None | |
| go_to("์ค์ตํ๊ธฐ") | |
| prev_col, next_col = st.columns(2) | |
| if prev_col.button("์ด์ ์นด๋", use_container_width=True, disabled=index == 0): | |
| prev_lesson = lessons[max(0, index - 1)] | |
| st.session_state.lab_lesson_index = next( | |
| (i for i, l in enumerate(all_lessons) if l.id == prev_lesson.id), 0 | |
| ) | |
| st.rerun() | |
| if next_col.button("๋ค์ ์นด๋", use_container_width=True, disabled=index >= len(lessons) - 1): | |
| next_lesson = lessons[min(len(lessons) - 1, index + 1)] | |
| st.session_state.lab_lesson_index = next( | |
| (i for i, l in enumerate(all_lessons) if l.id == next_lesson.id), 0 | |
| ) | |
| st.rerun() | |
| def render_learning_quiz(): | |
| st.subheader("ํ์ธ ํด์ฆ") | |
| track_id = selected_lab_track() | |
| track = track_by_id(track_id) | |
| certification = certification_for_track(track_id) | |
| st.caption(f"{track['name']} Track ยท {certification['name']} ๋๋น") | |
| quizzes = quizzes_for_track(track_id) | |
| if not quizzes: | |
| st.info("์์ง ์ค๋น๋ ํ์ธ ํด์ฆ๊ฐ ์์ต๋๋ค.") | |
| return | |
| # ์ค๋ ๋ณต์ต ์์ ํด์ฆ๋ฅผ ๋งจ ์์ ๋ฐฐ์น | |
| due_ids = set(lab_spaced_review_due_today()) | |
| due_quizzes = [q for q in quizzes if q.id in due_ids] | |
| other_quizzes = [q for q in quizzes if q.id not in due_ids] | |
| ordered_quizzes = due_quizzes + other_quizzes | |
| if due_quizzes: | |
| st.info(f"์ค๋ ๋ณต์ต ์์ ํด์ฆ {len(due_quizzes)}๊ฐ๊ฐ ์์ ๋ฐฐ์น๋์์ต๋๋ค.") | |
| index = min(st.session_state.lab_quiz_index, len(ordered_quizzes) - 1) | |
| quiz = ordered_quizzes[index] | |
| is_due = quiz.id in due_ids | |
| # ์ ์ฒด ํด์ฆ ์ง๋ ํ์ | |
| completed_quizzes = st.session_state.lab_completed_quizzes | |
| done_q = len([q for q in quizzes if q.id in completed_quizzes]) | |
| st.progress(done_q / len(quizzes) if quizzes else 0, text=f"ํด์ฆ {done_q}/{len(quizzes)} ์๋ฃ") | |
| with st.container(border=True): | |
| badge = "๐ ๋ณต์ต" if is_due else quiz.difficulty | |
| st.caption(f"{quiz.track} ยท {quiz.question_type} ยท {badge}") | |
| st.markdown(f"### ๋ฌธ์ {index + 1}/{len(ordered_quizzes)}") | |
| st.write(quiz.question) | |
| if quiz.question_type == "multiple_choice": | |
| answer = st.radio("๋ต", quiz.options, key=f"lab_quiz_answer_{quiz.id}") | |
| else: | |
| answer = st.text_input("๋ช ๋ น์ด ์ ๋ ฅ", key=f"lab_quiz_answer_{quiz.id}", placeholder="๋ช ๋ น์ด๋ฅผ ์ ๋ ฅํ์ธ์") | |
| source = doc_source_by_id(quiz.source_id) | |
| if source: | |
| st.markdown(f"์ถ์ฒ: [{source.provider} ยท {source.title}]({source.url})") | |
| if st.button("์ ๋ต ํ์ธ", type="primary", use_container_width=True): | |
| record_activity(track_id, "quiz", 1) | |
| correct, detail_tokens = evaluate_lab_quiz_detail(quiz, answer) | |
| if correct: | |
| st.session_state.lab_completed_quizzes.add(quiz.id) | |
| save_completed_items( | |
| st.session_state.lab_completed_lessons, | |
| st.session_state.lab_completed_quizzes, | |
| st.session_state.lab_completed_practices, | |
| ) | |
| mark_learning_step(track_id, "quiz") | |
| st.success("์ ๋ต์ ๋๋ค.") | |
| else: | |
| # command ํ์ : ํ ํฐ๋ณ ํผ๋๋ฐฑ | |
| if quiz.question_type == "command" and len(detail_tokens) > 1: | |
| parts_html = " ".join( | |
| f'<span style="color:{"green" if ok else "red"};font-weight:bold">{tok}</span>' | |
| for tok, ok in detail_tokens | |
| ) | |
| st.error("์ค๋ต์ ๋๋ค.") | |
| st.markdown(f"**์ ๋ต ๋ถ์:** {parts_html} ", unsafe_allow_html=True) | |
| st.caption("์ด๋ก์ = ์ ๋ ฅ๋จ / ๋นจ๊ฐ์ = ๋๋ฝ ๋๋ ์ค๋ฅ") | |
| else: | |
| st.error(f"์ค๋ต์ ๋๋ค. ์ ๋ต: `{quiz.answer}`") | |
| # ์ค๋ต๋ ธํธ ์ ์ฅ | |
| wrong_ids = {item["id"] for item in st.session_state.lab_wrong_notes} | |
| if quiz.id not in wrong_ids: | |
| st.session_state.lab_wrong_notes.append({ | |
| "id": quiz.id, | |
| "item_type": "quiz", | |
| "track": quiz.track, | |
| "question": quiz.question, | |
| "user_answer": str(answer), | |
| "correct_answer": quiz.answer, | |
| "explanation": quiz.explanation, | |
| }) | |
| save_wrong_notes(st.session_state.lab_wrong_notes) | |
| # ๊ฐ๊ฒฉ ๋ฐ๋ณต ๊ฐฑ์ (๋ชจ๋ ํด์ฆ ํ์ ) | |
| update_lab_spaced_review(quiz.id, correct) | |
| # DB ์ฐ๋ (multiple_choice๋ง) | |
| if quiz.question_type == "multiple_choice" and quiz.options: | |
| try: | |
| _db = SessionLocal() | |
| try: | |
| from cert_study_app.models import Question as _Question | |
| db_q = _db.query(_Question).filter(_Question.chunk_key == quiz.id).first() | |
| if db_q: | |
| try: | |
| opts = list(quiz.options) | |
| chosen_letter = chr(ord("A") + opts.index(str(answer))) if str(answer) in opts else None | |
| if chosen_letter: | |
| QuizService(_db).answer(db_q.id, chosen_letter, DEFAULT_USER) | |
| except Exception: | |
| pass | |
| update_spaced_review(db_q.id, correct) | |
| finally: | |
| _db.close() | |
| except Exception: | |
| pass | |
| st.markdown('<div class="answer-explanation">', unsafe_allow_html=True) | |
| st.markdown(quiz.explanation) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| # ๊ด๋ จ ๋ ์จ ๋ฐ๋ก๊ฐ๊ธฐ (์ค๋ต ์) | |
| if not correct and quiz.lesson_id: | |
| all_lessons = lessons_for_track(track_id) | |
| lesson_ids = [l.id for l in all_lessons] | |
| if quiz.lesson_id in lesson_ids: | |
| if st.button("์ด ๋ ์จ ๋ค์ ๋ณด๊ธฐ", key=f"goto_lesson_{quiz.id}"): | |
| st.session_state.lab_lesson_index = lesson_ids.index(quiz.lesson_id) | |
| go_to("์ด๋ก ํ์ต") | |
| # ๋ค์ ํด์ฆ ๋ฐ๋ก ์ด๋ (์ค๋ช ๋ฐ๋ก ์๋) | |
| is_last = index >= len(ordered_quizzes) - 1 | |
| if not is_last: | |
| if st.button("๋ค์ ํด์ฆ โ", type="primary", use_container_width=True, key=f"next_quiz_inline_{quiz.id}"): | |
| st.session_state.lab_quiz_index = index + 1 | |
| st.rerun() | |
| else: | |
| st.info("๋ง์ง๋ง ํด์ฆ์ ๋๋ค. ์ฒ์์ผ๋ก ๋์๊ฐ๊ฑฐ๋ ํ์์ ๋ค์ ๋จ๊ณ๋ก ์ด์ด๊ฐ์ธ์.") | |
| prev_col, next_col = st.columns(2) | |
| if prev_col.button("์ด์ ํด์ฆ", use_container_width=True, disabled=index == 0): | |
| st.session_state.lab_quiz_index = max(0, index - 1) | |
| st.rerun() | |
| if next_col.button("๋ค์ ํด์ฆ", use_container_width=True, disabled=index >= len(ordered_quizzes) - 1): | |
| st.session_state.lab_quiz_index = min(len(ordered_quizzes) - 1, index + 1) | |
| st.rerun() | |
| def _exam_elapsed_seconds(start_iso: str) -> int: | |
| try: | |
| start = datetime.fromisoformat(start_iso) | |
| return int((datetime.now() - start).total_seconds()) | |
| except Exception: | |
| return 0 | |
| def render_exam_mode(): | |
| st.subheader("์ํ ๋ชจ๋") | |
| session = st.session_state.get("exam_session") | |
| # โโ ๊ฒฐ๊ณผ ํ๋ฉด โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| if session and session.get("status") == "finished": | |
| answers = session.get("answers", []) | |
| total = len(answers) | |
| correct_count = sum(1 for a in answers if a.get("correct")) | |
| score = int(correct_count / total * 100) if total else 0 | |
| elapsed = _exam_elapsed_seconds(session["start_time"]) | |
| elapsed_str = f"{elapsed // 60}๋ถ {elapsed % 60}์ด" | |
| pass_line = 70 | |
| passed = score >= pass_line | |
| result_emoji = "ํฉ๊ฒฉ" if passed else "๋ถํฉ๊ฒฉ" | |
| st.markdown(f"## ๋ชจ์์ํ ์๋ฃ โ {result_emoji}") | |
| m1, m2, m3 = st.columns(3) | |
| m1.metric("์ ์", f"{score}์ ") | |
| m2.metric("์ ๋ต", f"{correct_count}/{total}") | |
| m3.metric("์์ ์๊ฐ", elapsed_str) | |
| if passed: | |
| st.success(f"ํฉ๊ฒฉ์ ({pass_line}์ ) ์ด์์ ๋๋ค.") | |
| else: | |
| st.warning(f"ํฉ๊ฒฉ์ ({pass_line}์ )์ {pass_line - score}์ ๋ถ์กฑํฉ๋๋ค.") | |
| wrong_answers = [a for a in answers if not a.get("correct")] | |
| if wrong_answers: | |
| st.markdown(f"### ํ๋ฆฐ ๋ฌธ์ ({len(wrong_answers)}๊ฐ)") | |
| for i, a in enumerate(wrong_answers): | |
| with st.expander(f"Q{i+1}. {a['question'][:60]}"): | |
| st.write(a["question"]) | |
| st.markdown(f"**๋ด ๋ต:** {a['user_answer']}") | |
| st.markdown(f"**์ ๋ต:** {a['correct_answer']}") | |
| if a.get("explanation"): | |
| st.info(a["explanation"]) | |
| # ์ค๋ต๋ ธํธ์ ์ ์ฅ | |
| note_key = a.get("quiz_id", "") | |
| wrong_ids = {n["id"] for n in st.session_state.lab_wrong_notes} | |
| if note_key and note_key not in wrong_ids: | |
| if st.button("์ค๋ต๋ ธํธ์ ์ถ๊ฐ", key=f"exam_wrong_{i}_{note_key}"): | |
| st.session_state.lab_wrong_notes.append({ | |
| "id": note_key, | |
| "item_type": "quiz", | |
| "track": session.get("track", "linux"), | |
| "question": a["question"], | |
| "user_answer": a["user_answer"], | |
| "correct_answer": a["correct_answer"], | |
| "explanation": a.get("explanation", ""), | |
| }) | |
| save_wrong_notes(st.session_state.lab_wrong_notes) | |
| st.success("์ ์ฅ๋์์ต๋๋ค.") | |
| if st.button("๋ค์ ์ํ", type="primary", use_container_width=True): | |
| st.session_state.exam_session = None | |
| st.rerun() | |
| return | |
| # โโ ์งํ ์ค ํ๋ฉด โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| if session and session.get("status") == "running": | |
| questions = session["questions"] | |
| cur_idx = session["current_index"] | |
| duration_min = session["duration_minutes"] | |
| elapsed = _exam_elapsed_seconds(session["start_time"]) | |
| remaining = max(0, duration_min * 60 - elapsed) | |
| remaining_str = f"{remaining // 60}๋ถ {remaining % 60}์ด" | |
| progress_val = cur_idx / len(questions) if questions else 0 | |
| st.progress(progress_val, text=f"๋ฌธ์ {cur_idx + 1}/{len(questions)}") | |
| time_col, _ = st.columns([1, 3]) | |
| if remaining == 0: | |
| time_col.error("โฐ ์๊ฐ ์ข ๋ฃ") | |
| else: | |
| time_col.info(f"โฑ ๋จ์ ์๊ฐ: {remaining_str}") | |
| q = questions[cur_idx] | |
| with st.container(border=True): | |
| st.markdown(f"### Q{cur_idx + 1}. {q['question']}") | |
| if q["question_type"] == "multiple_choice": | |
| user_ans = st.radio("๋ต ์ ํ", q["options"], key=f"exam_q_{cur_idx}") | |
| else: | |
| user_ans = st.text_input("๋ช ๋ น์ด ์ ๋ ฅ", key=f"exam_q_{cur_idx}", placeholder="๋ช ๋ น์ด๋ฅผ ์ ๋ ฅํ์ธ์") | |
| submit_disabled = remaining == 0 and cur_idx < len(questions) - 1 | |
| btn_label = "์ ์ถ ํ ๋ค์" if cur_idx < len(questions) - 1 else "์ ์ถ ํ ๊ฒฐ๊ณผ ๋ณด๊ธฐ" | |
| if st.button(btn_label, type="primary", use_container_width=True): | |
| from cert_study_app.services.learning_lab_service import LabQuiz | |
| fake_quiz = LabQuiz( | |
| id=q["quiz_id"], | |
| lesson_id=q.get("lesson_id", ""), | |
| track=session.get("track", "linux"), | |
| question_type=q["question_type"], | |
| question=q["question"], | |
| options=q.get("options", []), | |
| answer=q["answer"], | |
| explanation=q.get("explanation", ""), | |
| ) | |
| correct = evaluate_lab_quiz(fake_quiz, str(user_ans)) | |
| session["answers"].append({ | |
| "quiz_id": q["quiz_id"], | |
| "question": q["question"], | |
| "user_answer": str(user_ans), | |
| "correct_answer": q["answer"], | |
| "correct": correct, | |
| "explanation": q.get("explanation", ""), | |
| }) | |
| if cur_idx + 1 >= len(questions) or remaining == 0: | |
| session["status"] = "finished" | |
| else: | |
| session["current_index"] = cur_idx + 1 | |
| st.rerun() | |
| return | |
| # โโ ์ค์ ํ๋ฉด โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| st.caption("ํธ๋๊ณผ ๋ฌธ์ ์, ์ ํ ์๊ฐ์ ์ค์ ํ๊ณ ์ํ์ ์์ํฉ๋๋ค.") | |
| track_options = {t["name"]: t["id"] for t in active_tracks()} | |
| selected_track_name = st.selectbox("ํธ๋", list(track_options.keys())) | |
| selected_track_id = track_options[selected_track_name] | |
| question_count = st.selectbox("๋ฌธ์ ์", [5, 10, 20], index=1) | |
| duration_minutes = st.selectbox("์ ํ ์๊ฐ(๋ถ)", [10, 20, 40], index=1) | |
| difficulty_filter = st.multiselect("๋์ด๋", ["easy", "medium", "hard"], default=["easy", "medium", "hard"]) | |
| all_quizzes = quizzes_for_track(selected_track_id) | |
| pool = [q for q in all_quizzes if q.difficulty in difficulty_filter] | |
| with st.container(border=True): | |
| st.write(f"- ํธ๋: **{selected_track_name}**") | |
| st.write(f"- ์ถ์ ๊ฐ๋ฅ: {len(pool)}๋ฌธ์ ์ค {min(question_count, len(pool))}๋ฌธ์ ๋๋ค ์ถ์ ") | |
| st.write(f"- ์ ํ ์๊ฐ: {duration_minutes}๋ถ") | |
| start_disabled = len(pool) == 0 | |
| if start_disabled: | |
| st.warning("์ ํํ ์กฐ๊ฑด์ ๋ง๋ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.") | |
| if st.button("์ํ ์์", type="primary", use_container_width=True, disabled=start_disabled): | |
| chosen = random.sample(pool, min(question_count, len(pool))) | |
| st.session_state.exam_session = { | |
| "status": "running", | |
| "track": selected_track_id, | |
| "questions": [ | |
| { | |
| "quiz_id": q.id, | |
| "lesson_id": q.lesson_id, | |
| "question_type": q.question_type, | |
| "question": q.question, | |
| "options": list(q.options), | |
| "answer": q.answer, | |
| "explanation": q.explanation, | |
| } | |
| for q in chosen | |
| ], | |
| "current_index": 0, | |
| "start_time": datetime.now().isoformat(), | |
| "duration_minutes": duration_minutes, | |
| "answers": [], | |
| } | |
| st.rerun() | |
| def render_lab_practice(): | |
| st.subheader("์ค์ตํ๊ธฐ") | |
| st.caption("ํ์ฌ๋ Docker ํฐ๋ฏธ๋์ด ์๋๋ผ fake terminal simulator์ ๋๋ค.") | |
| track_id = selected_lab_track() | |
| tasks = [task for task in PRACTICE_TASKS if task.track == track_id and task.status == "approved"] | |
| if not tasks: | |
| st.info("์ด Track์ ์ค์ต์ ์์ง ์ค๋น ์ค์ ๋๋ค. ํ์ฌ fake terminal ์ค์ต์ Linux / LFCS ์ค์ฌ์ผ๋ก ์ ๊ณตํฉ๋๋ค.") | |
| return | |
| index = min(st.session_state.lab_practice_index, len(tasks) - 1) | |
| task = tasks[index] | |
| # ์ ์ฒด ์ค์ต ์ง๋ ํ์ | |
| completed_practices = st.session_state.lab_completed_practices | |
| done_p = len([t for t in tasks if t.id in completed_practices]) | |
| st.progress(done_p / len(tasks) if tasks else 0, text=f"์ค์ต {done_p}/{len(tasks)} ์๋ฃ") | |
| with st.container(border=True): | |
| st.caption(f"{task.track} ยท {task.difficulty} ยท {task.status}") | |
| st.markdown(f"### {task.title}") | |
| st.write(task.task_description) | |
| command = st.text_input("ํฐ๋ฏธ๋ ์ ๋ ฅ", key=f"practice_command_{task.id}", placeholder=task.expected_command) | |
| with st.expander("ํํธ", expanded=False): | |
| st.write(task.hint) | |
| if st.button("์ค์ต ์ฑ์ ", type="primary", use_container_width=True): | |
| all_pass, condition_results = evaluate_practice_detail(task, command) | |
| if all_pass: | |
| st.session_state.lab_completed_practices.add(task.id) | |
| save_completed_items( | |
| st.session_state.lab_completed_lessons, | |
| st.session_state.lab_completed_quizzes, | |
| st.session_state.lab_completed_practices, | |
| ) | |
| mark_learning_step(track_id, "apply") | |
| record_activity(track_id, "practice", 1) | |
| st.success(f"โ ์ ๋ต์ ๋๋ค. ์ค๋ ํ๋ {study_units():.1f}๋จ์") | |
| if task.takeaway: | |
| st.info(f"ํต์ฌ ํฌ์ธํธ: {task.takeaway}") | |
| else: | |
| st.error("์์ง ์กฐ๊ฑด์ ๋ง์กฑํ์ง ๋ชปํ์ต๋๋ค.") | |
| for cond, ok in condition_results: | |
| icon = "โ " if ok else "โ" | |
| st.markdown(f"{icon} `{cond}`") | |
| st.markdown('<div class="answer-explanation">', unsafe_allow_html=True) | |
| st.markdown(task.explanation) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| # ์ค๋ต ์ ๊ด๋ จ ๋ ์จ ๋ฐ๋ก๊ฐ๊ธฐ (lesson.related_practices ์ญ๋ฐฉํฅ ๋งคํ) | |
| if not all_pass: | |
| all_lessons = lessons_for_track(track_id) | |
| related_lessons = [l for l in all_lessons if task.id in l.related_practices] | |
| if related_lessons: | |
| lesson_ids = [l.id for l in all_lessons] | |
| matched = related_lessons[0] | |
| if st.button(f"๊ด๋ จ ์ด๋ก ๋ค์ ๋ณด๊ธฐ โ {matched.title}", key=f"goto_lesson_from_practice_{task.id}"): | |
| st.session_state.lab_lesson_index = lesson_ids.index(matched.id) | |
| go_to("์ด๋ก ํ์ต") | |
| # ์ฑ์ ํ ๋ค์ ์ค์ต ๋ฐ๋ก ์ด๋ | |
| is_last_task = index >= len(tasks) - 1 | |
| if not is_last_task: | |
| if st.button("๋ค์ ์ค์ต โ", type="primary", use_container_width=True, key=f"next_practice_inline_{task.id}"): | |
| st.session_state.lab_practice_index = index + 1 | |
| st.rerun() | |
| else: | |
| if all_pass: | |
| st.info("๐ ๋ชจ๋ ์ค์ต์ ์๋ฃํ์ต๋๋ค! ํ์์ ๋ค์ ๋จ๊ณ(์ค๋ต ๋ณต์ต)๋ก ์ด์ด๊ฐ์ธ์.") | |
| prev_col, next_col = st.columns(2) | |
| if prev_col.button("์ด์ ์ค์ต", use_container_width=True, disabled=index == 0): | |
| st.session_state.lab_practice_index = max(0, index - 1) | |
| st.rerun() | |
| if next_col.button("๋ค์ ์ค์ต", use_container_width=True, disabled=index >= len(tasks) - 1): | |
| st.session_state.lab_practice_index = min(len(tasks) - 1, index + 1) | |
| st.rerun() | |
| def render_progress(): | |
| st.subheader("์ง๋์จ") | |
| completed_lessons = set(st.session_state.lab_completed_lessons) | |
| completed_quizzes = set(st.session_state.lab_completed_quizzes) | |
| completed_practices = set(st.session_state.lab_completed_practices) | |
| for track in active_tracks(): | |
| certification = certification_for_track(track["id"]) | |
| progress = track_progress(track["id"], completed_lessons, completed_quizzes, completed_practices) | |
| with st.container(border=True): | |
| st.markdown(f"**{track['name']}**") | |
| st.caption(f"{track['description']} ยท ๋ชฉํ ์๊ฒฉ์ฆ: {certification['name']}") | |
| st.progress(progress["percent"] / 100 if progress["total"] else 0, text=f"{progress['completed']}/{progress['total']} ์๋ฃ") | |
| st.metric("์ค๋ ์๋ฃํ ํ์ต", len(completed_lessons) + len(completed_quizzes) + len(completed_practices)) | |
| st.markdown("#### AZ-104 ๋ฌธ์ ์ํ ์์ญ ๋ถํฌ") | |
| db = SessionLocal() | |
| try: | |
| rows = ( | |
| db.query(Question.category, func.count(Question.id)) | |
| .filter(Question.source == "AZ-104") | |
| .group_by(Question.category) | |
| .order_by(func.count(Question.id).desc()) | |
| .all() | |
| ) | |
| if not rows: | |
| st.caption("์์ง AZ-104 ๋ฌธ์ ๋ถ๋ฅ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค.") | |
| else: | |
| total = sum(count for _category, count in rows) | |
| for category, count in rows: | |
| ratio = count / total if total else 0 | |
| st.progress(ratio, text=f"{concept_label(category)} ยท {count}๋ฌธํญ") | |
| finally: | |
| db.close() | |
| def render_content_management(): | |
| st.subheader("์ฝํ ์ธ ๊ด๋ฆฌ") | |
| st.caption("ํ์ฌ๋ ๊ธฐ์กด ๊ด๋ฆฌ ๊ธฐ๋ฅ์ผ๋ก ์ด๋ํ๋ ํ๋ธ์ ๋๋ค.") | |
| col1, col2 = st.columns(2) | |
| if col1.button("PDF ์ ๋ก๋", use_container_width=True): | |
| go_to("PDF ์ ๋ก๋") | |
| if col2.button("์ฒ๋ฆฌ ํํฉ", use_container_width=True): | |
| go_to("์ฒ๋ฆฌ ํํฉ") | |
| col3, col4 = st.columns(2) | |
| if col3.button("์ํ ํํฉ", use_container_width=True): | |
| go_to("์ํ ํํฉ") | |
| if col4.button("AI ์์ธ", use_container_width=True): | |
| go_to("AI ์์ธ") | |
| st.markdown("#### ์ฝํ ์ธ ์ํ") | |
| st.write("์์ฑ ์ฝํ ์ธ ์ํ๊ฐ์ `generated`, `reviewed`, `approved`, `rejected`๋ฅผ ๊ธฐ์ค์ผ๋ก ํ์ฅํฉ๋๋ค.") | |
| with st.expander("AZ-104 ๋ถ๋ฅ ๊ฒ์", expanded=False): | |
| render_classification_review("AZ-104") | |
| def render_classification_review(source="AZ-104"): | |
| db = SessionLocal() | |
| try: | |
| categories = [ | |
| "az104_identity_governance", | |
| "az104_storage", | |
| "az104_compute", | |
| "az104_networking", | |
| "az104_monitor_recovery", | |
| ] | |
| category_label_map = {category: CATEGORY_LABELS.get(category, category) for category in categories} | |
| selected_category_label = st.selectbox( | |
| "๊ฒ์ํ ๋๋ถ๋ฅ", | |
| ["์ ์ฒด"] + list(category_label_map.values()), | |
| key="review_category_filter", | |
| ) | |
| selected_category = None | |
| if selected_category_label != "์ ์ฒด": | |
| selected_category = next(category for category, label in category_label_map.items() if label == selected_category_label) | |
| query = db.query(Question).filter(Question.source == source) | |
| if selected_category: | |
| query = query.filter(Question.category == selected_category) | |
| questions = query.order_by(Question.question_number.asc(), Question.id.asc()).limit(20).all() | |
| if not questions: | |
| st.caption("๊ฒ์ํ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.") | |
| return | |
| st.caption("ํ์์๋ ๋ซ์๋๊ณ , ์๋ ๋ถ๋ฅ๊ฐ ์ด์ํ ๋ฌธ์ ๋ง ๊ณ ์น๋ฉด ๋ฉ๋๋ค.") | |
| category_options = categories + ["uncategorized"] | |
| for question in questions: | |
| with st.container(border=True): | |
| number = question.question_number or question.id | |
| st.markdown(f"**๋ฌธ์ {number}๋ฒ**") | |
| st.caption((question.stem or "")[:180]) | |
| current_category = question.category if question.category in category_options else "uncategorized" | |
| category_index = category_options.index(current_category) | |
| new_category = st.selectbox( | |
| "๋๋ถ๋ฅ", | |
| category_options, | |
| index=category_index, | |
| format_func=lambda value: CATEGORY_LABELS.get(value, value), | |
| key=f"class_category_{question.id}", | |
| ) | |
| subcategory_options = sorted(SUBCATEGORY_LABELS.keys()) | |
| current_subcategory = question.subcategory if question.subcategory in subcategory_options else None | |
| sub_labels = ["๋ฏธ์ง์ "] + subcategory_options | |
| sub_index = sub_labels.index(current_subcategory) if current_subcategory in sub_labels else 0 | |
| new_subcategory = st.selectbox( | |
| "์ธ๋ถ ๊ฐ๋ ", | |
| sub_labels, | |
| index=sub_index, | |
| format_func=lambda value: "๋ฏธ์ง์ " if value == "๋ฏธ์ง์ " else SUBCATEGORY_LABELS.get(value, value), | |
| key=f"class_subcategory_{question.id}", | |
| ) | |
| if st.button("๋ถ๋ฅ ์ ์ฅ", use_container_width=True, key=f"save_classification_{question.id}"): | |
| question.category = new_category | |
| question.subcategory = None if new_subcategory == "๋ฏธ์ง์ " else new_subcategory | |
| db.commit() | |
| st.success("๋ถ๋ฅ๋ฅผ ์ ์ฅํ์ต๋๋ค.") | |
| st.rerun() | |
| finally: | |
| db.close() | |
| 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) | |
| PARENT_STEM_HEADINGS: frozenset[str] = frozenset({ | |
| "๊ฐ์", | |
| "์ผ๋ฐ ๊ฐ์", | |
| "๊ธฐ์กด ํ๊ฒฝ", | |
| "ํ๊ฒฝ", | |
| "์๊ตฌ์ฌํญ", | |
| "์๊ตฌ ์ฌํญ", | |
| "๊ณํ๋ ๋ณ๊ฒฝ", | |
| "๊ธฐ์ ์๊ตฌ ์ฌํญ", | |
| "์ฌ์ฉ์ ์๊ตฌ ์ฌํญ", | |
| "์ธ์ฆ ์๊ตฌ ์ฌํญ", | |
| "๋ถ์ ์๊ตฌ ์ฌํญ", | |
| "๋คํธ์ํฌ ์ธํ๋ผ", | |
| "Active Directory ํ๊ฒฝ", | |
| "๋ผ์ด์ผ์ค ๋ฌธ์ ", | |
| "๋ฌธ์ ์ค๋ช ", | |
| }) | |
| 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): | |
| 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 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 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 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 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 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 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_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 | |
| def render_quiz_controls(service, source, current_question=None): | |
| with st.expander("๋ฌธ์ ์ด๋", expanded=False): | |
| mode = st.radio( | |
| "ํ์ด ์์", | |
| ["์์๋๋ก", "๋๋ค"], | |
| horizontal=True, | |
| key="quiz_order_mode", | |
| ) | |
| max_number = max(1, service.max_question_number(source)) | |
| st.caption(f"๋ฌธ์ ๋ฒํธ ๋ฒ์: 1๋ฒ ~ {max_number}๋ฒ ยท ์ฒ๋ฆฌ ์ ๋ฒํธ๋ ์ด๋ํ ์ ์์ต๋๋ค.") | |
| number = st.number_input( | |
| "๋ฌธ์ ๋ฒํธ๋ก ์ด๋", | |
| min_value=1, | |
| max_value=max_number, | |
| step=1, | |
| value=min(max_number, int((current_question or {}).get("number") or 1)), | |
| key=f"jump_number_{(current_question or {}).get('id', 'none')}", | |
| ) | |
| col1, col2 = st.columns(2) | |
| if col1.button("๋ฒํธ ์ด๋", use_container_width=True): | |
| target = service.get_question_by_number(int(number), source) | |
| if not target: | |
| status = service.question_status_by_number(int(number), source) | |
| if not status: | |
| st.warning("ํด๋น ๋ฒํธ์ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.") | |
| elif status["status"] == "needs_visual": | |
| st.warning( | |
| f"{status['number']}๋ฒ์ ํ์ฌ '{status_label(status['status'])}' ์ํ๋ผ " | |
| "ํ์ด ํ๋ฉด์์ ์ ์ธ๋์ด ์์ต๋๋ค. ์ฒ๋ฆฌ ํํฉ์์ ์ด๋ฏธ์ง ๋ถ์์ ๋จผ์ ์คํํด ์ฃผ์ธ์." | |
| ) | |
| elif status["status"] in {"draft", "needs_review"}: | |
| st.warning( | |
| f"{status['number']}๋ฒ์ ํ์ฌ '{status_label(status['status'])}' ์ํ๋ผ " | |
| "ํ์ด ํ๋ฉด์์ ์ ์ธ๋์ด ์์ต๋๋ค. ์ฒ๋ฆฌ ํํฉ์์ ๋ณด์ ํ ์ด๋ํ ์ ์์ต๋๋ค." | |
| ) | |
| else: | |
| st.warning( | |
| f"{status['number']}๋ฒ์ ํ์ฌ '{status_label(status['status'])}' ์ํ๋ผ " | |
| "ํ์ด ํ๋ฉด์์ ์ ์ธ๋์ด ์์ต๋๋ค." | |
| ) | |
| else: | |
| st.session_state.question_id = target["id"] | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| if col2.button("๋๋ค ๋ฌธ์ ", use_container_width=True): | |
| target = service.get_random_question(source, st.session_state.question_id) | |
| if not target: | |
| st.warning("๋๋ค์ผ๋ก ๊ฐ์ ธ์ฌ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.") | |
| else: | |
| st.session_state.question_id = target["id"] | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| return mode or "์์๋๋ก" | |
| def render_quiz(source=None): | |
| db, service = get_service() | |
| try: | |
| category, subcategory = render_quiz_skill_filter(db, source) | |
| filtered_source = "AZ-104" if category and source is None else source | |
| if category: | |
| question = service.get_unit_question( | |
| st.session_state.question_id, | |
| source=filtered_source, | |
| category=category, | |
| subcategory=subcategory, | |
| ) | |
| else: | |
| question = service.get_question(st.session_state.question_id, source) | |
| if not question: | |
| st.info("์ ํํ ์ํ์ ๋ฑ๋ก๋ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.") | |
| return | |
| st.session_state.question_id = question["id"] | |
| order_mode = render_quiz_controls(service, filtered_source, question) | |
| render_question_header(question) | |
| render_question_body(question) | |
| render_question_image(question) | |
| st.session_state.selected = render_answer_input(question, "quiz") | |
| if st.button("์ฑ์ ", type="primary", use_container_width=True): | |
| if not st.session_state.selected: | |
| st.warning("๋ต์ ๋จผ์ ์ ํํด ์ฃผ์ธ์.") | |
| else: | |
| chosen = str(st.session_state.selected).strip() | |
| result = service.answer(question["id"], chosen, DEFAULT_USER) | |
| st.session_state.last_result = result | |
| update_spaced_review(question["id"], result["correct"]) | |
| record_activity(track_for_question_source(question.get("source")), "cert_question", 1) | |
| st.rerun() | |
| prev_col, next_col = st.columns(2) | |
| with prev_col: | |
| if st.button("์ด์ ", use_container_width=True): | |
| if category: | |
| previous_question = service.previous_unit_question( | |
| question["id"], | |
| source=filtered_source, | |
| category=category, | |
| subcategory=subcategory, | |
| ) | |
| else: | |
| previous_question = service.previous_question(question["id"], source) | |
| if previous_question.get("start"): | |
| st.info("์ฒซ ๋ฒ์งธ ๋ฌธ์ ์ ๋๋ค.") | |
| else: | |
| st.session_state.question_id = previous_question["id"] | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| with next_col: | |
| if st.button("๋ค์", use_container_width=True): | |
| if order_mode == "๋๋ค": | |
| if category: | |
| next_question = service.get_random_unit_question( | |
| source=filtered_source, | |
| category=category, | |
| subcategory=subcategory, | |
| exclude_id=question["id"], | |
| ) or {"end": True} | |
| else: | |
| next_question = service.get_random_question(source, question["id"]) or {"end": True} | |
| else: | |
| if category: | |
| next_question = service.next_unit_question( | |
| question["id"], | |
| source=filtered_source, | |
| category=category, | |
| subcategory=subcategory, | |
| ) | |
| else: | |
| next_question = service.next_question(question["id"], source) | |
| if next_question.get("end"): | |
| st.success("๋ง์ง๋ง ๋ฌธ์ ์ ๋๋ค.") | |
| else: | |
| st.session_state.question_id = next_question["id"] | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| if st.button("๋ณต์ต ์ถ๊ฐ", use_container_width=True): | |
| service.add_review(question["id"], DEFAULT_USER) | |
| st.toast("๋ณต์ต ๋ชฉ๋ก์ ์ถ๊ฐํ์ต๋๋ค.") | |
| if st.button("๊ฐ์ ๋จ์/๊ฐ๋ ๊ณ์ ํ๊ธฐ", use_container_width=True): | |
| similar_type = service.similar_type_from_question(question["id"]) | |
| if similar_type: | |
| st.session_state.similar_type = similar_type | |
| st.session_state.exam_source = similar_type["source"] | |
| st.session_state.question_id = None | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| go_to("๊ฐ์ ๋จ์ ํ์ต") | |
| else: | |
| st.warning("๊ฐ์ ๋จ์/๊ฐ๋ ๋ฌธ์ ๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค.") | |
| if st.session_state.last_result: | |
| render_answer_result(st.session_state.last_result) | |
| render_concept_candidates(question) | |
| if st.toggle("์ง์์๋ต", key=f"show_qa_{question['id']}"): | |
| render_quiz_assistant(question, source) | |
| finally: | |
| db.close() | |
| def render_weak_quiz(source=None): | |
| db, service = get_service() | |
| try: | |
| weak_types = service.weak_types(DEFAULT_USER, source) | |
| if not weak_types: | |
| st.info("์์ง ์ค๋ต ๊ธฐ๋ก์ด ์์ต๋๋ค. ๋ฌธ์ ๋ฅผ ํ๊ณ ์ฝํ ๊ฐ๋ ์ด ์๊ธฐ๋ฉด ์ฌ๊ธฐ์์ ์ง์ค ํ์ตํ ์ ์์ต๋๋ค.") | |
| return | |
| labels = [item["label"] for item in weak_types] | |
| current = st.session_state.weak_type | |
| current_label = current.get("label") if isinstance(current, dict) else None | |
| index = labels.index(current_label) if current_label in labels else 0 | |
| selected_label = st.selectbox("ํ์ตํ ์ทจ์ฝ ๊ฐ๋ ", labels, index=index) | |
| selected = weak_types[labels.index(selected_label)] | |
| if selected != st.session_state.weak_type: | |
| st.session_state.weak_type = selected | |
| st.session_state.question_id = None | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| question = service.get_weak_question( | |
| st.session_state.question_id, | |
| source=source, | |
| category=selected["category"] or None, | |
| subcategory=selected.get("subcategory") or None, | |
| ) | |
| if not question: | |
| st.info("์ ํํ ๊ฐ๋ ์ ํด๋นํ๋ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.") | |
| return | |
| st.session_state.question_id = question["id"] | |
| render_question_header(question, selected["label"]) | |
| render_question_body(question) | |
| render_question_image(question) | |
| st.session_state.selected = render_answer_input(question, "weak") | |
| if st.button("์ฑ์ ", type="primary", use_container_width=True): | |
| if not st.session_state.selected: | |
| st.warning("๋ต์ ๋จผ์ ์ ํํด ์ฃผ์ธ์.") | |
| else: | |
| chosen = str(st.session_state.selected).strip() | |
| result = service.answer(question["id"], chosen, DEFAULT_USER) | |
| st.session_state.last_result = result | |
| update_spaced_review(question["id"], result["correct"]) | |
| record_activity(track_for_question_source(question.get("source")), "cert_question", 1) | |
| st.rerun() | |
| prev_col, next_col = st.columns(2) | |
| with prev_col: | |
| if st.button("๊ฐ์ ๊ฐ๋ ์ด์ ๋ฌธ์ ", use_container_width=True): | |
| previous_question = service.previous_weak_question( | |
| question["id"], | |
| source=source, | |
| category=selected["category"] or None, | |
| subcategory=selected.get("subcategory") or None, | |
| ) | |
| if previous_question.get("start"): | |
| st.info("์ ํํ ๊ฐ๋ ์ ์ฒซ ๋ฒ์งธ ๋ฌธ์ ์ ๋๋ค.") | |
| else: | |
| st.session_state.question_id = previous_question["id"] | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| with next_col: | |
| if st.button("๊ฐ์ ๊ฐ๋ ๋ค์ ๋ฌธ์ ", use_container_width=True): | |
| next_question = service.next_weak_question( | |
| question["id"], | |
| source=source, | |
| category=selected["category"] or None, | |
| subcategory=selected.get("subcategory") or None, | |
| ) | |
| if next_question.get("end"): | |
| st.success("์ ํํ ๊ฐ๋ ์ ๋ง์ง๋ง ๋ฌธ์ ์ ๋๋ค.") | |
| else: | |
| st.session_state.question_id = next_question["id"] | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| if st.session_state.last_result: | |
| render_answer_result(st.session_state.last_result) | |
| finally: | |
| db.close() | |
| def render_similar_quiz(): | |
| similar_type = st.session_state.similar_type | |
| if not similar_type: | |
| st.info("๋จผ์ ๋ฌธ์ ํ์ด์์ ๊ธฐ์ค ๋ฌธ์ ๋ฅผ ์ ํํด ์ฃผ์ธ์.") | |
| return | |
| source = similar_type.get("source") | |
| category = similar_type.get("category") | |
| question_type = similar_type.get("question_type") | |
| db, service = get_service() | |
| try: | |
| st.caption(similar_type["label"]) | |
| question = service.get_unit_question( | |
| st.session_state.question_id, | |
| source=source, | |
| category=category, | |
| subcategory=similar_type.get("subcategory"), | |
| question_type=question_type, | |
| ) | |
| if not question: | |
| st.info("๊ฐ์ ๋จ์/๊ฐ๋ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.") | |
| return | |
| st.session_state.question_id = question["id"] | |
| render_question_header(question, similar_type["label"]) | |
| render_question_body(question) | |
| render_question_image(question) | |
| st.session_state.selected = render_answer_input(question, "similar") | |
| if st.button("์ฑ์ ", type="primary", use_container_width=True): | |
| if not st.session_state.selected: | |
| st.warning("๋ต์ ๋จผ์ ์ ํํด ์ฃผ์ธ์.") | |
| else: | |
| chosen = str(st.session_state.selected).strip() | |
| st.session_state.last_result = service.answer(question["id"], chosen, DEFAULT_USER) | |
| st.rerun() | |
| prev_col, next_col = st.columns(2) | |
| with prev_col: | |
| if st.button("์ด์ ๋ฌธ์ ", use_container_width=True): | |
| previous_question = service.previous_unit_question( | |
| question["id"], | |
| source=source, | |
| category=category, | |
| subcategory=similar_type.get("subcategory"), | |
| question_type=question_type, | |
| ) | |
| if previous_question.get("start"): | |
| st.info("์ฒซ ๋ฒ์งธ ๋ฌธ์ ์ ๋๋ค.") | |
| else: | |
| st.session_state.question_id = previous_question["id"] | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| with next_col: | |
| if st.button("๋ค์ ๋ฌธ์ ", use_container_width=True): | |
| next_question = service.next_unit_question( | |
| question["id"], | |
| source=source, | |
| category=category, | |
| subcategory=similar_type.get("subcategory"), | |
| question_type=question_type, | |
| ) | |
| if next_question.get("end"): | |
| st.success("๋ง์ง๋ง ๋ฌธ์ ์ ๋๋ค.") | |
| else: | |
| st.session_state.question_id = next_question["id"] | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| st.rerun() | |
| if st.session_state.last_result: | |
| render_answer_result(st.session_state.last_result) | |
| finally: | |
| db.close() | |
| def render_notes(source=None): | |
| concept_notes = st.session_state.get("lab_wrong_notes", []) | |
| db, service = get_service() | |
| try: | |
| payload = service.wrong_review(DEFAULT_USER, source) | |
| finally: | |
| db.close() | |
| db_count = payload["count"] | |
| concept_count = len(concept_notes) | |
| total = db_count + concept_count | |
| st.subheader(f"์ค๋ต/๋ณต์ต {total}๊ฐ") | |
| tab_labels = [ | |
| f"AZ-104 ๋คํ ์ค๋ต ({db_count})", | |
| f"๊ฐ๋ ํ์ต ์ค๋ต ยท ์ด๋ก /CLI ({concept_count})", | |
| ] | |
| tab_cert, tab_concept = st.tabs(tab_labels) | |
| with tab_cert: | |
| if not payload["items"]: | |
| st.info("์๊ฒฉ์ฆ ๋ฌธ์ ์ค๋ต์ด ์์ต๋๋ค.") | |
| db2, service2 = get_service() | |
| try: | |
| for item in payload["items"]: | |
| title_parts = [f"#{item['question_id']}"] | |
| if item.get("source"): | |
| title_parts.append(item["source"]) | |
| title_parts.append(item["stem"][:80]) | |
| with st.expander(" ยท ".join(title_parts)): | |
| st.write(item["stem"]) | |
| st.write("์ ๋ต:", item["answer"]) | |
| if item.get("image_path") and Path(item["image_path"]).exists(): | |
| st.image(item["image_path"], use_container_width=True) | |
| if item.get("chosen"): | |
| st.write("๋ด ๋ต:", item["chosen"]) | |
| if item.get("explanation"): | |
| st.write(item["explanation"]) | |
| if item.get("category"): | |
| st.caption(f"์ธ๋ถ๊ฐ๋ : {item.get('concept_label')}") | |
| col_done, col_go = st.columns(2) | |
| if col_done.button("๋ณต์ต ์๋ฃ", use_container_width=True, key=f"review_done_{item['question_id']}"): | |
| src_track = track_for_question_source(item.get("source")) | |
| mark_learning_step(src_track, "review") | |
| record_activity(src_track, "review", 1) | |
| st.success(f"๋ณต์ต์ ๊ธฐ๋กํ์ต๋๋ค. ์ค๋ ํ๋ {study_units():.1f}๋จ์") | |
| if col_go.button("์ด ๋ฌธ์ ํ๊ธฐ", use_container_width=True, key=f"go_quiz_{item['question_id']}"): | |
| st.session_state.question_id = item["question_id"] | |
| st.session_state.exam_source = item.get("source") | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| go_to("์๊ฒฉ์ฆ ๋ฌธ์ ") | |
| concept_col, focus_col = st.columns(2) | |
| if concept_col.button( | |
| "๊ฐ์ ๊ฐ๋ ํ๊ธฐ", | |
| use_container_width=True, | |
| key=f"go_same_concept_{item['question_id']}", | |
| disabled=not item.get("category"), | |
| ): | |
| st.session_state.similar_type = { | |
| "source": item.get("source"), | |
| "category": item.get("category"), | |
| "subcategory": item.get("subcategory"), | |
| "question_type": None, | |
| "label": item.get("concept_label") or "๊ฐ์ ๊ฐ๋ ", | |
| } | |
| st.session_state.exam_source = item.get("source") | |
| st.session_state.question_id = None | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| go_to("๊ฐ์ ๋จ์ ํ์ต") | |
| if focus_col.button("Focus ๊ฐ๋ ๋ณด๊ธฐ", use_container_width=True, key=f"go_focus_{item['question_id']}"): | |
| track_id = track_for_question_source(item.get("source")) | |
| st.session_state.lab_track = track_id | |
| save_preferred_track(track_id) | |
| go_to("์ด๋ก ํ์ต") | |
| finally: | |
| db2.close() | |
| with tab_concept: | |
| if not concept_notes: | |
| st.info("๊ฐ๋ ํด์ฆ ์ค๋ต์ด ์์ต๋๋ค. ๊ฐ๋ ๊ณต๋ถ์์ ๋ฌธ์ ๋ฅผ ํ๋ฉด ํ๋ฆฐ ํญ๋ชฉ์ด ์ฌ๊ธฐ์ ์์ ๋๋ค.") | |
| else: | |
| if st.button("์ ์ฒด ์ด๊ธฐํ", key="clear_concept_wrong"): | |
| st.session_state.lab_wrong_notes = [] | |
| save_wrong_notes([]) | |
| st.rerun() | |
| for idx, note in enumerate(concept_notes): | |
| track_label = {"linux": "Linux", "azure": "Azure", "tool_docs": "Docs"}.get(note.get("track"), note.get("track", "")) | |
| header = f"[{track_label}] {note['question'][:70]}" | |
| with st.expander(header): | |
| st.write(note["question"]) | |
| col_ans, col_mine = st.columns(2) | |
| col_ans.markdown(f"**์ ๋ต** {note['correct_answer']}") | |
| col_mine.markdown(f"**๋ด ๋ต** {note['user_answer']}") | |
| if note.get("explanation"): | |
| st.info(note["explanation"]) | |
| if st.button("๋ณต์ต ์๋ฃ โ ๋ชฉ๋ก์์ ์ ๊ฑฐ", key=f"concept_done_{idx}_{note['id']}"): | |
| st.session_state.lab_wrong_notes = [ | |
| n for n in st.session_state.lab_wrong_notes if n["id"] != note["id"] | |
| ] | |
| save_wrong_notes(st.session_state.lab_wrong_notes) | |
| mark_learning_step(note.get("track", "linux"), "review") | |
| record_activity(note.get("track", "linux"), "review", 1) | |
| st.rerun() | |
| def options_to_text(options) -> str: | |
| if isinstance(options, dict): | |
| lines = [] | |
| for key, value in options.items(): | |
| value = str(value).strip() | |
| if re.match(r"^[A-Ea-e1-5][\.\)]\s+", value): | |
| lines.append(value) | |
| else: | |
| lines.append(f"{key}. {value}") | |
| return "\n".join(lines) | |
| if isinstance(options, list): | |
| return "\n".join(str(option) for option in options) | |
| return "" | |
| def parse_options_text(text: str) -> list[str]: | |
| return [line.strip() for line in text.splitlines() if line.strip()] | |
| def render_review(source=None): | |
| st.subheader("์ฒ๋ฆฌ ํํฉ") | |
| with st.expander("์ฒ๋ฆฌ ํํฉ์์ ๋ณด๋ ๊ฒ", expanded=False): | |
| st.markdown( | |
| """ | |
| ์ฒ๋ฆฌ ํํฉ์ PDF ์ ๋ก๋ ์ดํ ๋ฌธ์ ํ์ด ์ค๋น ๊ณผ์ ์ ํ ๊ณณ์์ ๋ณด์ฌ์ค๋๋ค. | |
| 1. Airflow ํ์ฑ ์์ ์ด ์งํ ์ค์ธ์ง ํ์ธํฉ๋๋ค. | |
| 2. ํ์ด ๊ฐ๋ฅ/์ด๋ฏธ์ง ๋ถ์ ๋๊ธฐ/์ง๋ฌธ ํ์ ๋ฌธํญ ์๋ฅผ ๋ด ๋๋ค. | |
| 3. ๋จ์ ์ด๋ฏธ์ง ๋ถ์๊ณผ ๊ฐ๋ ๋ถ๋ฅ๋ฅผ ๋ฐฑ๊ทธ๋ผ์ด๋๋ก ๋ณด์ํฉ๋๋ค. | |
| 4. ์ ๋งคํ ๋ฌธ์ ๋ง ์ง์ ํ์ธํฉ๋๋ค. | |
| ๋ค๊ฐ ์ง์ ๋ด์ผ ํ๋ ๊ฒ์ `์ง๋ฌธ ํ์`์ ๋จ์ ์ฒ์ ๋ณด๋ ํจํด๋ฟ์ ๋๋ค. | |
| """.strip() | |
| ) | |
| render_airflow_status() | |
| st.divider() | |
| render_ingestion_jobs(show_title=False) | |
| st.divider() | |
| db = SessionLocal() | |
| try: | |
| base = db.query(Question) | |
| if source: | |
| base = base.filter(Question.source == source) | |
| summary_state = automation_summary(db, source) | |
| counts = summary_state["status_counts"] | |
| col_a, col_b, col_c = st.columns(3) | |
| col_a.metric("์น์ธ ์๋ฃ", counts.get("approved", 0)) | |
| col_b.metric("์ด๋ฏธ์ง ๋ถ์ ๋๊ธฐ", summary_state["image_needed"]) | |
| col_c.metric("์ง๋ฌธ ํ์", summary_state["question_needed"]) | |
| with st.expander("์ด๋ฏธ์ง ๋ถ์ ๋๊ธฐ๋?", expanded=bool(summary_state["image_needed"])): | |
| st.markdown( | |
| """ | |
| `์ด๋ฏธ์ง ๋ถ์ ๋๊ธฐ`๋ ํ ์คํธ๋ง์ผ๋ก๋ ๋ฌธ์ ๋ฅผ ์์ ์ ์ผ๋ก ํ ์ ์๋ ์ํ์ ๋๋ค. | |
| ์ฃผ๋ก ์ด๋ฐ ๋ฌธ์ ์ ๋๋ค. | |
| - ์์/๋๋กญ๋ค์ด/ํซ์คํ ์ ํ์ง๊ฐ ์ด๋ฏธ์ง์๋ง ์๋ ๋ฌธ์ | |
| - Yes/No ์ง์ ํ์ด OCR๋ก ์ถฉ๋ถํ ์ ์กํ ๋ฌธ์ | |
| - qwen ์ด๋ฏธ์ง ๋ถ์์ด ์คํจํ๊ฑฐ๋ JSON ๊ตฌ์กฐ๊ฐ ๋ถ์์ ํ๋ ๋ฌธ์ | |
| - ์ฌ๋์ด ์ง์ ์๋ฌธ ์ด๋ฏธ์ง๋ฅผ ๋ณด๊ณ ํ/์ ํ์ง๋ฅผ ํ์ธํด์ผ ํ๋ ๋ฌธ์ | |
| """.strip() | |
| ) | |
| needs_visual_numbers = [ | |
| question.question_number or question.id | |
| for question in base.filter(Question.parse_status == "needs_visual") | |
| .order_by(Question.question_number.asc(), Question.id.asc()) | |
| .limit(80) | |
| .all() | |
| ] | |
| if needs_visual_numbers: | |
| st.caption("๋จ์ ๋ฒํธ: " + ", ".join(str(number) for number in needs_visual_numbers)) | |
| if summary_state["question_needed"]: | |
| st.warning("์ฒ์ ๋ณด๋ ํจํด์ด ์์ด์. ์๋ '์ง๋ฌธ ํ์'์์ ์ ํ์ ์๋ ค์ฃผ๋ฉด ๋ค์๋ถํฐ ์ฒ๋ฆฌ ๊ท์น์ ํ์ฉํฉ๋๋ค.") | |
| elif summary_state["image_needed"]: | |
| st.info("์ด๋ฏธ์ง ๋ถ์ ๋๊ธฐ ๋ฌธ์ ๊ฐ ๋จ์ ์์ต๋๋ค. ์๋์์ Airflow ์ด๋ฏธ์ง ๋ถ์์ ์คํํด ์ฃผ์ธ์.") | |
| else: | |
| st.success("ํต์ฌ ์ฒ๋ฆฌ๊ฐ ๋๋ฌ์ต๋๋ค. ๋จ์ ํญ๋ชฉ์ ์๋ ์์ธ ๋ชฉ๋ก์์ ํ์ธํ๋ฉด ๋ฉ๋๋ค.") | |
| with st.container(): | |
| col1, col2 = st.columns([2, 1]) | |
| concept_overwrite = col1.checkbox("๊ธฐ์กด AZ-104 ์์ญ ๋ถ๋ฅ๋ ๋ค์ ๊ณ์ฐ", value=False) | |
| if col2.button("AZ-104 ์์ญ ๋ถ๋ฅ", use_container_width=True): | |
| concept_summary = classify_question_batch( | |
| db, | |
| source=source, | |
| limit=1000, | |
| overwrite=concept_overwrite, | |
| ) | |
| st.success( | |
| f"AZ-104 ์์ญ ๋ถ๋ฅ {concept_summary['checked']}๊ฐ ยท " | |
| f"๋ถ๋ฅ๋จ {concept_summary['classified']}๊ฐ ยท " | |
| f"๋ฏธ๋ถ๋ฅ {concept_summary['uncategorized']}๊ฐ" | |
| ) | |
| st.rerun() | |
| with st.container(): | |
| col1, col2 = st.columns([2, 1]) | |
| airflow_visual_limit = col1.number_input( | |
| "Airflow ์ด๋ฏธ์ง ๋ถ์ ๊ฐ์", | |
| min_value=1, | |
| max_value=200, | |
| value=min(50, max(1, int(summary_state["image_needed"] or 1))), | |
| step=5, | |
| ) | |
| if col2.button("Airflow๋ก ์ด๋ฏธ์ง ๋ถ์ ์์", use_container_width=True): | |
| try: | |
| result = AirflowService().trigger_visual_analysis( | |
| source_name=source, | |
| limit=int(airflow_visual_limit), | |
| model=DEFAULT_VISUAL_MODEL, | |
| ) | |
| st.success(f"Airflow ์ด๋ฏธ์ง ๋ถ์ ์์ ์ ๋ฑ๋กํ์ต๋๋ค: {result.get('dag_run_id')}") | |
| except AirflowTriggerError as exc: | |
| st.error(str(exc)) | |
| st.caption("Airflow๊ฐ ์ผ์ ธ ์๋์ง, http://localhost:8080 ์ ์์ด ๋๋์ง ํ์ธํด ์ฃผ์ธ์.") | |
| with st.expander("๋ฌธ์ ์ ํ ๋ฉํ๋ฐ์ดํฐ", expanded=False): | |
| type_counts = summary_state["type_counts"] | |
| if not type_counts: | |
| st.caption("์์ง ์ ํ ๋ฉํ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.") | |
| for qtype, count in sorted(type_counts.items()): | |
| meta = type_metadata(qtype) | |
| st.markdown(f"**{meta['label']}** ยท {count}๊ฐ") | |
| st.caption(f"ํ์ฑ: {meta['parser']} ยท ํ์ด UI: {meta['ui']} ยท ์ด๋ฏธ์ง ํ์: {'์' if meta['needs_image'] else '์๋์ค'}") | |
| with st.expander("๊ฐ๋ ๋ถ๋ฅ ํํฉ", expanded=False): | |
| concept_rows = ( | |
| base.with_entities(Question.category, Question.subcategory, func.count(Question.id)) | |
| .group_by(Question.category, Question.subcategory) | |
| .order_by(func.count(Question.id).desc()) | |
| .all() | |
| ) | |
| if not concept_rows: | |
| st.caption("์์ง ๊ฐ๋ ๋ถ๋ฅ๊ฐ ์์ต๋๋ค.") | |
| for category, subcategory, count in concept_rows: | |
| st.markdown(f"**{concept_label(category, subcategory)}** ยท {count}๋ฌธํญ") | |
| status_options = ["needs_reparse", "needs_review", "needs_visual", "draft", "approved", "rejected", "all"] | |
| default_status = ( | |
| "needs_reparse" | |
| if summary_state.get("reparse_needed") | |
| else ("needs_visual" if summary_state["image_needed"] else ("needs_review" if summary_state["question_needed"] else "all")) | |
| ) | |
| status_filter = st.selectbox( | |
| "์ํ", | |
| status_options, | |
| index=status_options.index(default_status), | |
| format_func=lambda value: "์ ์ฒด" if value == "all" else status_label(value), | |
| ) | |
| query = base | |
| if status_filter != "all": | |
| query = query.filter(Question.parse_status == status_filter) | |
| questions = query.order_by(Question.id.asc()).limit(300).all() | |
| if not questions: | |
| st.info("์ ํํ ์ํ์ ๋ฌธ์ ๊ฐ ์์ต๋๋ค.") | |
| return | |
| ids = [question.id for question in questions] | |
| current_id = st.session_state.review_question_id | |
| index = ids.index(current_id) if current_id in ids else 0 | |
| selected_id = st.selectbox("๋ฌธ์ ", ids, index=index, format_func=lambda qid: f"#{qid}") | |
| st.session_state.review_question_id = selected_id | |
| question = db.query(Question).filter(Question.id == selected_id).first() | |
| if not question: | |
| st.warning("๋ฌธ์ ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค.") | |
| return | |
| if question.image_path and Path(question.image_path).exists(): | |
| if st.toggle("์๋ฌธ ์ด๋ฏธ์ง ๋ณด๊ธฐ", key=f"review_img_{question.id}", value=False): | |
| st.image(question.image_path, use_container_width=True) | |
| score_text = "๋ฏธ์คํ" if question.review_score is None else f"{question.review_score}์ " | |
| st.caption(f"{status_label(question.parse_status)} ยท ์๋ ์ ์ {score_text}") | |
| if question.quality_status: | |
| st.caption( | |
| f"ํ์ง: {question.quality_status}" | |
| + (f" ยท ์ ์ {question.quality_score}" if question.quality_score is not None else "") | |
| + (f" ยท chunk {question.chunk_index}" if question.chunk_index is not None else "") | |
| ) | |
| if question.quality_issues: | |
| try: | |
| quality_issues = json.loads(question.quality_issues) | |
| except Exception: | |
| quality_issues = [] | |
| quality_codes = [ | |
| str(issue.get("code")) | |
| for issue in quality_issues | |
| if isinstance(issue, dict) and issue.get("code") | |
| ] | |
| if quality_codes: | |
| st.warning("ํ์ง ์ด์: " + " / ".join(quality_codes)) | |
| try: | |
| structured = json.loads(question.structured_data_json or "{}") | |
| except Exception: | |
| structured = {} | |
| meta = structured.get("question_type_metadata") or type_metadata(question.question_type) | |
| st.caption(f"์ ํ: {meta.get('label')} ยท ํ์ฑ ๋ฐฉ์: {meta.get('parser')} ยท ํ์ด UI: {meta.get('ui')}") | |
| if question.review_issues: | |
| try: | |
| issues = json.loads(question.review_issues) | |
| except Exception: | |
| issues = [question.review_issues] | |
| if issues: | |
| st.warning(" / ".join(str(issue) for issue in issues)) | |
| form_title = "์ง๋ฌธ ํ์ ํญ๋ชฉ ์์ " if question.parse_status in {"needs_review", "draft"} else "๋ฌธ์ ๊ตฌ์กฐ ํ์ธ" | |
| visual_types = {"hotspot", "table_choice", "matching", "ordering", "yes_no"} | |
| existing_visual = visual_analysis_data(question) | |
| show_visual_editor = (question.question_type or "").lower() in visual_types or bool(existing_visual) | |
| st.markdown(f"#### {form_title}") | |
| with st.form(f"review_form_{question.id}"): | |
| stem = st.text_area("๋ฌธ์ ๋ณธ๋ฌธ", value=question.stem or "", height=180) | |
| type_options = ["mcq", "multi_select", "yes_no", "matching", "ordering", "table_choice", "case_study", "hotspot", "unparsed"] | |
| current_type = (question.question_type or "unparsed").lower() | |
| type_index = type_options.index(current_type) if current_type in type_options else 0 | |
| question_type = st.selectbox( | |
| "๋ฌธ์ ์ ํ", | |
| type_options, | |
| index=type_index, | |
| ) | |
| options_text = st.text_area("๋ณด๊ธฐ", value=options_to_text(question.get_options()), height=140) | |
| answer = st.text_input("์ ๋ต", value=question.answer or "") | |
| explanation = st.text_area("ํด์ค", value=question.explanation or "", height=120) | |
| visual_source = "" | |
| visual_areas_text = "" | |
| if show_visual_editor: | |
| st.markdown("##### ์ด๋ฏธ์ง ๊ธฐ๋ฐ ๊ตฌ์กฐ") | |
| visual_source = st.text_area( | |
| "๋ฌธ์ ๊ทผ๊ฑฐ/์ด๋ฏธ์ง ์ค๋ช ", | |
| value=visual_source_content(question), | |
| height=110, | |
| help="์ด๋ฏธ์ง ์์ ์ฝ๋, ํ, ์ค์ ๊ฐ, ๋ค์ด์ด๊ทธ๋จ ํ ์คํธ์ฒ๋ผ ๋ฌธ์ ํ์ด์ ํ์ํ ์๋ฌธ ๋ด์ฉ์ ์ ์ต๋๋ค.", | |
| ) | |
| visual_areas_text = st.text_area( | |
| "์ด๋ฏธ์ง ๋ต๋ณ ์์ญ", | |
| value=visual_answer_areas_to_text(visual_answer_areas(question)), | |
| height=120, | |
| help="ํ๋ง๋ค '์ผ์ชฝ ๋ฌธ๊ตฌ | ์ ํ์ง1, ์ ํ์ง2 | ์ ๋ต' ํ์์ผ๋ก ์ ๋ ฅํฉ๋๋ค.", | |
| ) | |
| review_note = st.text_area("๊ฒ์ ๋ฉ๋ชจ", value=question.review_note or "", height=80) | |
| raw_text = st.text_area("OCR ์๋ฌธ", value=question.raw_text or question.stem or "", height=120) | |
| col1, col2, col3 = st.columns(3) | |
| save = col1.form_submit_button("์์ ์ ์ฅ", use_container_width=True) | |
| approve = col2.form_submit_button("ํ์ด ๊ฐ๋ฅ ์ฒ๋ฆฌ", type="primary", use_container_width=True) | |
| reject = col3.form_submit_button("์ ์ธ", use_container_width=True) | |
| if save or approve or reject: | |
| question.stem = stem.strip() | |
| question.question_type = question_type | |
| question.answer = answer.strip() | |
| question.explanation = explanation.strip() | |
| question.review_note = review_note.strip() | |
| question.raw_text = raw_text.strip() | |
| question.set_options(parse_options_text(options_text)) | |
| visual_areas = parse_visual_answer_areas_text(visual_areas_text) if show_visual_editor else [] | |
| if show_visual_editor: | |
| visual_payload = dict(existing_visual) | |
| visual_payload.update( | |
| { | |
| "ok": True, | |
| "model": visual_payload.get("model") or "manual-review", | |
| "question_type": question.question_type, | |
| "stem": question.stem, | |
| "source_content": visual_source.strip(), | |
| "answer_areas": visual_areas, | |
| "options": question.get_options(), | |
| "confidence": max(int(visual_payload.get("confidence") or 0), 95 if approve else 80), | |
| "notes": "์๋ ๋ณด์ ", | |
| } | |
| ) | |
| question.visual_analysis_json = json.dumps(visual_payload, ensure_ascii=False) | |
| if not question.answer and visual_areas: | |
| question.answer = visual_selected_answers(visual_areas) | |
| question.structured_data_json = json.dumps( | |
| { | |
| "stem": question.stem, | |
| "options": question.get_options(), | |
| "answer": question.answer, | |
| "explanation": question.explanation, | |
| "question_type": question.question_type, | |
| "question_type_metadata": type_metadata(question.question_type), | |
| "visual_analysis": visual_analysis_data(question), | |
| }, | |
| ensure_ascii=False, | |
| ) | |
| if approve: | |
| question.parse_status = "approved" | |
| question.reviewed_at = datetime.utcnow() | |
| elif reject: | |
| question.parse_status = "rejected" | |
| question.reviewed_at = datetime.utcnow() | |
| elif not question.parse_status: | |
| question.parse_status = "draft" | |
| db.commit() | |
| st.success("์ ์ฅํ์ต๋๋ค.") | |
| st.rerun() | |
| finally: | |
| db.close() | |
| def render_upload(exams): | |
| st.subheader("์ํ๋ณ PDF ์ ๋ก๋") | |
| existing_names = [exam["name"] for exam in exams] | |
| selected_exam = "" | |
| if existing_names: | |
| selected_exam = st.selectbox("๊ธฐ์กด ์ํ ๋ถ๋ฌ์ค๊ธฐ", [""] + existing_names) | |
| exam_name = st.text_input( | |
| "์ํ๋ช ", | |
| value=selected_exam, | |
| placeholder="์: AZ-104, AWS SAA-C03, ์ ๋ณด์ฒ๋ฆฌ๊ธฐ์ฌ", | |
| ) | |
| uploaded = st.file_uploader("PDF", type=["pdf"], label_visibility="collapsed") | |
| use_llm = st.checkbox("LLM ํ์ฑ ์ฌ์ฉ", value=False) | |
| auto_visual_analysis = st.checkbox("์ด๋ฏธ์ง ๋ถ์๊น์ง ์๋ ์คํ", value=True) | |
| visual_batch_size = 0 | |
| if auto_visual_analysis: | |
| analyze_all_images = st.checkbox("์ด๋ฏธ์ง ๋ถ์ ๋๊ธฐ ๋ฌธ์ ์ ์ฒด ์ฒ๋ฆฌ", value=False) | |
| if analyze_all_images: | |
| visual_batch_size = 10000 | |
| st.caption("Airflow ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์ ์ฒด ์ด๋ฏธ์ง ๋ถ์์ ์๋ํฉ๋๋ค. PDF๊ฐ ํฌ๋ฉด ์ค๋ ๊ฑธ๋ฆด ์ ์์ต๋๋ค.") | |
| else: | |
| visual_batch_size = st.number_input("์ ๋ก๋ ํ qwen ์ด๋ฏธ์ง ๋ถ์ ๊ฐ์", min_value=1, max_value=50, value=5, step=1) | |
| llm_model = DEFAULT_MAIN_MODEL | |
| ollama_base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") | |
| if use_llm: | |
| llm_model = st.text_input("Ollama ๋ชจ๋ธ", value=llm_model) | |
| ollama_base_url = st.text_input("Ollama URL", value=ollama_base_url) | |
| if uploaded and st.button("ํ์ฑ ์์ ๋ฑ๋ก", type="primary"): | |
| exam_name = exam_name.strip() | |
| if not exam_name: | |
| st.warning("์ํ๋ช ์ ์ ๋ ฅํด ์ฃผ์ธ์.") | |
| return | |
| ensure_runtime_dirs() | |
| safe_name = Path(uploaded.name).name | |
| target = f"data/uploads/{slugify(exam_name)}_{safe_name}" | |
| with open(target, "wb") as f: | |
| f.write(uploaded.getbuffer()) | |
| db = SessionLocal() | |
| try: | |
| job_service = IngestionJobService(db) | |
| job = job_service.create_job( | |
| exam_name=exam_name, | |
| pdf_path=target, | |
| use_llm=use_llm, | |
| llm_model=llm_model, | |
| ollama_base_url=ollama_base_url, | |
| auto_visual_analysis=auto_visual_analysis, | |
| visual_batch_size=int(visual_batch_size), | |
| ) | |
| job_id = job.id | |
| try: | |
| AirflowService().trigger_pdf_ingestion( | |
| job_id=job_id, | |
| pdf_path=target, | |
| source_name=exam_name, | |
| use_llm=use_llm, | |
| llm_model=llm_model, | |
| ollama_base_url=ollama_base_url, | |
| ) | |
| job_service.mark_queued(job_id, "Airflow DAG ์คํ์ ์์ฒญํ์ต๋๋ค.") | |
| except AirflowTriggerError as exc: | |
| job_service.fail_job(job_id, str(exc)) | |
| finally: | |
| db.close() | |
| st.success(f"ํ์ฑ ์์ #{job_id}์ ๋ฑ๋กํ์ต๋๋ค.") | |
| go_to("์ฒ๋ฆฌ ํํฉ") | |
| def render_airflow_status(): | |
| st.markdown("#### Airflow DAG ์ํ") | |
| try: | |
| airflow = AirflowService() | |
| dag_labels = [ | |
| ("cert_study_pdf_ingestion", "PDF ํ์ฑ"), | |
| ("cert_study_visual_analysis", "์ด๋ฏธ์ง ๋ถ์"), | |
| ] | |
| for dag_id, label in dag_labels: | |
| runs = airflow.list_dag_runs(dag_id, limit=3) | |
| if not runs: | |
| st.caption(f"{label}: ์คํ ๊ธฐ๋ก ์์") | |
| continue | |
| latest = runs[0] | |
| state = latest.get("state") or "unknown" | |
| run_id = latest.get("dag_run_id") or "" | |
| started = latest.get("start_date") or "-" | |
| ended = latest.get("end_date") or "-" | |
| if state == "success": | |
| st.success(f"{label}: success ยท {run_id}") | |
| elif state in {"running", "queued"}: | |
| st.info(f"{label}: {state} ยท {run_id}") | |
| elif state == "failed": | |
| st.error(f"{label}: failed ยท {run_id}") | |
| else: | |
| st.caption(f"{label}: {state} ยท {run_id}") | |
| st.caption(f"์์ {started} ยท ์ข ๋ฃ {ended}") | |
| except AirflowTriggerError as exc: | |
| st.warning("Airflow ์ํ๋ฅผ ๋ถ๋ฌ์ค์ง ๋ชปํ์ต๋๋ค.") | |
| st.caption(str(exc)) | |
| def render_ingestion_jobs(show_title=True): | |
| if show_title: | |
| st.subheader("์ต๊ทผ ํ์ฑ ์์ ") | |
| db = SessionLocal() | |
| try: | |
| jobs = [ | |
| { | |
| "id": job.id, | |
| "exam_name": job.exam_name, | |
| "pdf_path": job.pdf_path, | |
| "status": job.status, | |
| "stage": job.stage, | |
| "message": job.message, | |
| "current": job.current, | |
| "total": job.total, | |
| "inserted": job.inserted, | |
| "output_json": job.output_json, | |
| "quality_score": job.quality_score, | |
| "quality_status": job.quality_status, | |
| "quality_report_json": job.quality_report_json, | |
| "quality_gate_json": job.quality_gate_json, | |
| "error_message": job.error_message, | |
| } | |
| for job in IngestionJobService(db).list_jobs() | |
| ] | |
| finally: | |
| db.close() | |
| if st.button("์์ ์ํ ์๋ก๊ณ ์นจ", use_container_width=True): | |
| st.rerun() | |
| if not jobs: | |
| st.info("๋ฑ๋ก๋ ํ์ฑ ์์ ์ด ์์ต๋๋ค.") | |
| return | |
| for job in jobs: | |
| title = f"#{job['id']} {job['exam_name']} ยท {job['status']}" | |
| with st.expander(title, expanded=job["status"] in {"queued", "running", "held"}): | |
| st.write(job["pdf_path"]) | |
| ratio = min(max((job["current"] or 0) / max(job["total"] or 1, 1), 0), 1) | |
| st.progress(ratio) | |
| st.caption(f"{job['stage']}: {job['message'] or ''}") | |
| st.caption(f"{job['current'] or 0} / {job['total'] or 1} ยท ์ ์ฌ {job['inserted'] or 0}๊ฐ") | |
| if job.get("quality_status"): | |
| st.caption(f"ํ์ง ๊ฒ์ดํธ: {job['quality_status']} ยท ์ ์ {job.get('quality_score') if job.get('quality_score') is not None else '-'}") | |
| if job["error_message"]: | |
| st.error(job["error_message"][:1200]) | |
| render_quality_gate_report(job.get("quality_gate_json")) | |
| render_parse_quality_report(job.get("output_json"), job.get("quality_report_json")) | |
| log_path = Path("data/run_logs") / f"job_{job['id']}.log" | |
| if log_path.exists(): | |
| if st.toggle("๋ก๊ทธ ๋ณด๊ธฐ", key=f"show_job_log_{job['id']}"): | |
| st.code(log_path.read_text(encoding="utf-8")[-3000:]) | |
| def render_quality_gate_report(gate_json): | |
| if not gate_json: | |
| return | |
| gate_path = Path(gate_json) | |
| if not gate_path.exists(): | |
| return | |
| try: | |
| gate = json.loads(gate_path.read_text(encoding="utf-8")) | |
| except Exception: | |
| return | |
| status = gate.get("status") or "unknown" | |
| action = gate.get("action") or "unknown" | |
| reason = gate.get("reason") or "" | |
| if action == "hold": | |
| st.error(f"์๋ ํ์ : ๋ณด๋ฅ ยท {status}") | |
| elif action == "proceed_with_review": | |
| st.warning(f"์๋ ํ์ : ๊ฒฝ๊ณ ์ ์ฌ ยท {status}") | |
| else: | |
| st.success(f"์๋ ํ์ : ํต๊ณผ ยท {status}") | |
| if reason: | |
| st.caption(reason) | |
| def render_parse_quality_report(output_json, quality_report_json=None): | |
| if not output_json and not quality_report_json: | |
| st.caption("ํ์ฑ ํ์ง ๋ฆฌํฌํธ: ์์ง ์์ฑ ์ ") | |
| return | |
| report_path = Path(quality_report_json or default_quality_report_path(output_json)) | |
| if not report_path.exists(): | |
| st.caption("ํ์ฑ ํ์ง ๋ฆฌํฌํธ: ์์") | |
| return | |
| try: | |
| report = json.loads(report_path.read_text(encoding="utf-8")) | |
| except Exception as exc: | |
| st.warning(f"ํ์ฑ ํ์ง ๋ฆฌํฌํธ๋ฅผ ์ฝ์ง ๋ชปํ์ต๋๋ค: {exc}") | |
| return | |
| score = int(report.get("score") or 0) | |
| status = report.get("status") or "unknown" | |
| question_count = int(report.get("question_count") or 0) | |
| if score >= 85: | |
| st.success(f"ํ์ฑ ํ์ง {score}์ ยท {status} ยท {question_count}๋ฌธํญ") | |
| elif score >= 65: | |
| st.warning(f"ํ์ฑ ํ์ง {score}์ ยท {status} ยท {question_count}๋ฌธํญ") | |
| else: | |
| st.error(f"ํ์ฑ ํ์ง {score}์ ยท {status} ยท {question_count}๋ฌธํญ") | |
| with st.expander("ํ์ฑ/์ฒญํน ํ์ง ๋ฆฌํฌํธ", expanded=score < 85): | |
| issue_counts = report.get("issue_counts") or {} | |
| if issue_counts: | |
| st.dataframe( | |
| [{"์ด์": key, "๊ฐ์": value} for key, value in sorted(issue_counts.items(), key=lambda row: (-row[1], row[0]))], | |
| hide_index=True, | |
| use_container_width=True, | |
| ) | |
| else: | |
| st.caption("๊ฐ์ง๋ ๊ตฌ์กฐ ์ด์๊ฐ ์์ต๋๋ค.") | |
| metrics = report.get("metrics") or {} | |
| numbers = metrics.get("numbers") or {} | |
| chunks = metrics.get("chunk_lengths") or {} | |
| st.caption( | |
| f"๋ฒํธ {numbers.get('first')}~{numbers.get('last')} ยท " | |
| f"์ฒญํฌ ๊ธธ์ด min/median/max {chunks.get('min')}/{chunks.get('median')}/{chunks.get('max')}" | |
| ) | |
| if numbers.get("gaps"): | |
| st.warning("๋ฒํธ ๋๋ฝ ์์ฌ: " + ", ".join(f"{start}-{end}" if start != end else str(start) for start, end in numbers["gaps"][:12])) | |
| if numbers.get("duplicates"): | |
| st.warning("๋ฒํธ ์ค๋ณต ์์ฌ: " + ", ".join(str(number) for number in numbers["duplicates"][:20])) | |
| samples = report.get("samples") or [] | |
| if samples: | |
| st.markdown("##### ์ฐ์ ํ์ธํ ์ํ") | |
| for sample in samples[:10]: | |
| issue_text = " / ".join(issue.get("code", "") for issue in sample.get("issues", [])) | |
| st.markdown(f"**#{sample.get('number') or sample.get('index')} ยท p.{sample.get('page')}** ยท {issue_text}") | |
| st.caption(sample.get("stem_preview") or sample.get("raw_preview") or "") | |
| st.caption(f"๋ฆฌํฌํธ ํ์ผ: {report_path.as_posix()}") | |
| def render_quiz_assistant(current_question, source=None): | |
| ask_with_question = st.checkbox("ํ์ฌ ๋ฌธ์ ํฌํจ", value=True) | |
| question = st.text_area( | |
| "๊ถ๊ธํ ๋ด์ฉ", | |
| placeholder="์: ์ด ๋ฌธ์ ์์ ์ํ์ฅ ํจ์ ํฌ์ธํธ๊ฐ ๋ญ์ผ?", | |
| height=110, | |
| ) | |
| with st.expander("LLM ์ค์ "): | |
| model_options = [ | |
| { | |
| "mode": "fast", | |
| "label": f"๋น ๋ฅธ ๊ฒ์ ({DEFAULT_FAST_MODEL})", | |
| "model": DEFAULT_FAST_MODEL, | |
| "k": 2, | |
| "max_context_chars": 1600, | |
| }, | |
| { | |
| "mode": "normal", | |
| "label": f"์ผ๋ฐ ๊ฒ์ ({DEFAULT_MAIN_MODEL})", | |
| "model": DEFAULT_MAIN_MODEL, | |
| "k": 4, | |
| "max_context_chars": 3200, | |
| }, | |
| ] | |
| if DEFAULT_DEEP_MODEL: | |
| model_options.append( | |
| { | |
| "mode": "deep", | |
| "label": f"์ฌ์ธต ๊ฒ์ ({DEFAULT_DEEP_MODEL})", | |
| "model": DEFAULT_DEEP_MODEL, | |
| "k": 6, | |
| "max_context_chars": 4800, | |
| } | |
| ) | |
| model_label = st.selectbox( | |
| "๊ฒ์ ๋ชจ๋", | |
| [option["label"] for option in model_options], | |
| index=0, | |
| help="์๋๊ฐ ์ค์ํ๋ฉด ๋น ๋ฅธ ๊ฒ์์ ์ฌ์ฉํ์ธ์. ์ฌ์ธต ๊ฒ์์ OLLAMA_DEEP_MODEL์ ์ค์ ํ๋ฉด ๋ํ๋ฉ๋๋ค.", | |
| ) | |
| selected_model_option = next(option for option in model_options if option["label"] == model_label) | |
| llm_model = selected_model_option["model"] | |
| ollama_base_url = st.text_input( | |
| "Ollama URL", | |
| value=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"), | |
| ) | |
| embedding_model = st.selectbox( | |
| "์๋ฒ ๋ฉ ๋ชจ๋ธ", | |
| EMBEDDING_MODEL_OPTIONS, | |
| index=EMBEDDING_MODEL_OPTIONS.index(DEFAULT_EMBEDDING_MODEL) | |
| if DEFAULT_EMBEDDING_MODEL in EMBEDDING_MODEL_OPTIONS | |
| else 0, | |
| help="์ง์์๋ต ๊ฒ์์ ์ฌ์ฉํ ๋ฒกํฐ ์๋ฒ ๋ฉ ๋ชจ๋ธ์ ๋๋ค. ์์ธํ ๋ ์ฌ์ฉํ ๋ชจ๋ธ๊ณผ ๊ฐ์์ผ ๊ฒ์๋ฉ๋๋ค.", | |
| ) | |
| k = st.slider("๊ฒ์ ๋ฌธ์ ์", min_value=1, max_value=8, value=int(selected_model_option["k"])) | |
| db, service = SessionLocal(), None | |
| try: | |
| service = StudyAssistantService( | |
| db, | |
| vector_store=QuestionVectorStore(embedding_model=embedding_model), | |
| ) | |
| except Exception as exc: | |
| st.error(f"AI ๊ฒ์ ์๋น์ค ์ด๊ธฐํ ์คํจ: {exc}") | |
| st.caption("์๋ฒ ๋ฉ ๋ชจ๋ธ ๋ก๋๋ ChromaDB ์ด๊ธฐํ ์ค๋ฅ์ ๋๋ค. ์ ์ ํ ๋ค์ ์๋ํ๊ฑฐ๋ ์๋ฒ ๋ฉ ๋ชจ๋ธ ์ค์ ์ ํ์ธํ์ธ์.") | |
| db.close() | |
| return | |
| try: | |
| if st.button("์ง๋ฌธํ๊ธฐ", type="primary", use_container_width=True): | |
| if not question.strip(): | |
| st.warning("์ง๋ฌธ์ ์ ๋ ฅํด ์ฃผ์ธ์.") | |
| return | |
| prompt = question.strip() | |
| if ask_with_question and current_question: | |
| options_text = "\n".join(str(option) for option in current_question.get("options") or []) | |
| concept_bits = [ | |
| current_question.get("concept_label") or "", | |
| ", ".join(current_question.get("concept_tags") or []), | |
| ] | |
| concept_text = " / ".join(bit for bit in concept_bits if bit) | |
| prompt = ( | |
| "ํ์ฌ ๋ฌธ์ ๋ฅผ 1์์๋ก ๋ณด๊ณ ๋ต๋ณํด์ค. ๊ฒ์๋ ์ ์ฌ ๋ฌธ์ ๋ ๋ณด์กฐ ์ฐธ๊ณ ๋ก๋ง ์ฌ์ฉํด์ค.\n\n" | |
| f"ํ์ฌ ๋ฌธ์ ๋ฒํธ: {current_question.get('number')}\n" | |
| f"ํ์ฌ ๋ฌธ์ ์ ํ: {current_question.get('question_type') or ''}\n" | |
| f"ํ์ฌ ๋ฌธ์ ๊ฐ๋ : {concept_text or '๋ฏธ๋ถ๋ฅ'}\n\n" | |
| "ํ์ฌ ๋ฌธ์ ๋ณธ๋ฌธ:\n" | |
| f"{current_question.get('question') or ''}\n\n" | |
| "ํ์ฌ ๋ฌธ์ ๋ณด๊ธฐ:\n" | |
| f"{options_text or '๋ณด๊ธฐ ์์'}\n\n" | |
| f"๋ด ์ง๋ฌธ:\n{prompt}" | |
| ) | |
| try: | |
| with st.spinner("๊ด๋ จ ๋ฌธ์ ๋ฅผ ๊ฒ์ํ๋ ์ค์ ๋๋ค."): | |
| result = service.ask_stream( | |
| question=prompt, | |
| model=llm_model, | |
| base_url=ollama_base_url, | |
| k=k, | |
| source=source, | |
| max_context_chars=int(selected_model_option["max_context_chars"]), | |
| ) | |
| if result.get("cached"): | |
| st.caption("์บ์๋ ๋ต๋ณ") | |
| st.markdown(result["answer"]) | |
| else: | |
| answer_placeholder = st.empty() | |
| answer_chunks = [] | |
| for chunk in result["stream"]: | |
| answer_chunks.append(chunk) | |
| answer_placeholder.markdown("".join(answer_chunks)) | |
| with st.expander("๊ฒ์๋ ๊ทผ๊ฑฐ"): | |
| for search_result in result["sources"]: | |
| metadata = search_result["metadata"] | |
| source_type = metadata.get("source_type") or "question" | |
| title = metadata.get("title") or "" | |
| url = metadata.get("url") or "" | |
| st.caption( | |
| f"type={source_type} ยท id={search_result['id']} ยท " | |
| f"score={search_result['score']} ยท source={metadata.get('source', '')}" | |
| ) | |
| if title: | |
| st.markdown(f"**{title}**") | |
| if url: | |
| st.caption(url) | |
| st.write(search_result["text"]) | |
| except Exception as exc: | |
| st.error(f"AI ์ง๋ฌธ ์ฒ๋ฆฌ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: {exc}") | |
| st.caption(f"Ollama URL({ollama_base_url})์ด ์คํ ์ค์ธ์ง, ๋ชจ๋ธ({llm_model})์ด ์ค์น๋์ด ์๋์ง ํ์ธํ์ธ์.") | |
| finally: | |
| db.close() | |
| def render_vector_index(): | |
| st.subheader("AI ์์ธ") | |
| embedding_model = st.selectbox( | |
| "์๋ฒ ๋ฉ ๋ชจ๋ธ", | |
| EMBEDDING_MODEL_OPTIONS, | |
| index=EMBEDDING_MODEL_OPTIONS.index(DEFAULT_EMBEDDING_MODEL) | |
| if DEFAULT_EMBEDDING_MODEL in EMBEDDING_MODEL_OPTIONS | |
| else 0, | |
| help="ํ๊ตญ์ด ์ง๋ฌธ๊ณผ ์์ด ๊ณต์ Docs๋ฅผ ๊ฐ์ด ๊ฒ์ํ๋ ค๋ฉด BAAI/bge-m3๋ฅผ ์ถ์ฒํฉ๋๋ค.", | |
| ) | |
| st.caption(f"Chroma ์ปฌ๋ ์ ์ ์๋ฒ ๋ฉ ๋ชจ๋ธ๋ณ๋ก ๋ถ๋ฆฌ๋ฉ๋๋ค. ํ์ฌ ๋ชจ๋ธ: `{embedding_model}`") | |
| db, service = SessionLocal(), None | |
| try: | |
| service = StudyAssistantService( | |
| db, | |
| vector_store=QuestionVectorStore(embedding_model=embedding_model), | |
| ) | |
| if st.button("๋ฌธ์ DB ๋ฒกํฐ ์์ธ", type="primary", use_container_width=True): | |
| with st.spinner("๋ฌธ์ ์ ํด์ค์ Chroma์ ์์ธํ๋ ์ค์ ๋๋ค."): | |
| indexed = service.index_questions() | |
| st.success(f"{indexed}๊ฐ ๋ฌธํญ์ ์์ธํ์ต๋๋ค.") | |
| st.divider() | |
| st.markdown("#### ๊ณต์ Docs") | |
| source_options = docs_source_options() | |
| source_labels = [label for _, label in source_options] | |
| selected_label = st.selectbox("Docs ๋ฒ์", source_labels) | |
| selected_track = source_options[source_labels.index(selected_label)][0] | |
| selected_track_id = None if selected_track == "all" else selected_track | |
| sources = active_docs_sources(selected_track_id) | |
| st.caption(f"์ ํ๋ ๊ณต์ ๋ฌธ์ {len(sources)}๊ฐ") | |
| with st.expander("์์ธ ๋์ URL", expanded=False): | |
| for source in sources: | |
| st.markdown(f"- `{source.role}` ยท [{source.provider} ยท {source.title}]({source.url})") | |
| docs_service = OfficialDocsService(db, embedding_model=embedding_model, sources=sources) | |
| latest_sync = docs_service.latest_sync() | |
| if latest_sync: | |
| st.caption( | |
| f"๋ง์ง๋ง ๋๊ธฐํ: {latest_sync.status} ยท " | |
| f"{latest_sync.documents_indexed or 0}๊ฐ ๋ฌธ์ ยท " | |
| f"{latest_sync.chunks_indexed or 0}๊ฐ chunk ยท " | |
| f"{latest_sync.completed_at or latest_sync.created_at}" | |
| ) | |
| if latest_sync.error_message: | |
| st.error(latest_sync.error_message[:1000]) | |
| else: | |
| st.caption("์์ง ๊ณต์ Docs ์์ธ์ด ์์ต๋๋ค.") | |
| st.caption("๊ถ์ฅ ์ฃผ๊ธฐ: ๋ถ๊ธฐ 1ํ ยท ์ํ ์ง์ ์๋ ์๋ ๋๊ธฐํ๋ฅผ ํ ๋ฒ ์คํํ์ธ์.") | |
| max_docs = max(1, len(sources)) | |
| default_docs = min(12, max_docs) | |
| docs_limit = st.number_input("๋๊ธฐํํ Docs URL ์", min_value=1, max_value=max_docs, value=default_docs, step=1) | |
| if st.button("๊ณต์ Docs ๋ฒกํฐ ์์ธ", use_container_width=True): | |
| with st.spinner("๊ณต์ Docs๋ฅผ ๊ฐ์ ธ์ Chroma์ ์์ธํ๋ ์ค์ ๋๋ค. ์ฒซ ์คํ์ ๋ชจ๋ธ ๋ค์ด๋ก๋ ๋๋ฌธ์ ์ค๋ ๊ฑธ๋ฆด ์ ์์ต๋๋ค."): | |
| summary = docs_service.sync(limit=int(docs_limit)) | |
| if summary["status"] == "success": | |
| st.success(summary["message"]) | |
| else: | |
| st.error(summary["message"]) | |
| if summary.get("error_message"): | |
| st.caption(summary["error_message"]) | |
| finally: | |
| db.close() | |
| def render_concept_notes(source=None): | |
| st.subheader("๊ฐ๋ ์ ๋ฆฌ") | |
| db = SessionLocal() | |
| try: | |
| service = ConceptNoteService(db) | |
| query = st.text_input("๊ฐ๋ ๊ฒ์", placeholder="์: Load Balancer, NSG, Recovery Services Vault") | |
| notes = service.list_notes(source=source, query=query, limit=100) | |
| if not notes: | |
| st.info("์์ง ์ ์ฅ๋ ๊ฐ๋ ์ด ์์ต๋๋ค. ๋ฌธ์ ๋ฅผ ํ๊ณ ์ฑ์ ํ '๊ฐ๋ ํ๋ณด ๋ณด๊ธฐ'์์ ํ์ํ ๊ฐ๋ ๋ง ์ ์ฅํด ๋ณด์ธ์.") | |
| return | |
| labels = [f"{note.concept_name} ยท #{note.id}" for note in notes] | |
| selected = st.selectbox("๊ฐ๋ ", range(len(notes)), format_func=lambda idx: labels[idx]) | |
| note = notes[selected] | |
| st.markdown(f"### {note.concept_name}") | |
| if note.summary: | |
| st.markdown("#### ํต์ฌ ์์ฝ") | |
| st.write(note.summary) | |
| if note.exam_point: | |
| st.markdown("#### ์ํ ํฌ์ธํธ") | |
| st.write(note.exam_point) | |
| if note.trap_point: | |
| st.markdown("#### ํท๊ฐ๋ฆด ํฌ์ธํธ") | |
| st.write(note.trap_point) | |
| keywords = note.keyword_list() | |
| if keywords: | |
| st.caption(" ยท ".join(keywords)) | |
| st.divider() | |
| st.markdown("#### ๊ด๋ จ ๋ฌธ์ ") | |
| related = service.related_questions(note, limit=20) | |
| if not related: | |
| st.caption("์์ง ์ฐ๊ฒฐ๋ ๊ด๋ จ ๋ฌธ์ ๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค.") | |
| return | |
| for question in related: | |
| with st.container(border=True): | |
| number = question.question_number or question.id | |
| st.markdown(f"**๋ฌธ์ {number}๋ฒ**") | |
| st.write((question.stem or "")[:240]) | |
| if st.button("์ด ๋ฌธ์ ํ๊ธฐ", key=f"concept_related_{note.id}_{question.id}", use_container_width=True): | |
| st.session_state.exam_source = question.source | |
| st.session_state.question_id = question.id | |
| st.session_state.selected = None | |
| st.session_state.last_result = None | |
| go_to("๋ฌธ์ ํ์ด") | |
| finally: | |
| db.close() | |
| def learning_landing_routes(): | |
| return { | |
| "๊ฐ๋ ๊ณต๋ถ": render_concept_mode_home, | |
| "๊ฐ๋ ๊ณต๋ถ": render_concept_mode_home, | |
| "์ค์ต": render_practice_mode_home, | |
| "์ํ์ค๋น": render_exam_prep_home, | |
| "์ํ ์ค๋น": render_exam_prep_home, | |
| } | |
| def main(): | |
| ensure_runtime_dirs() | |
| init_db(verbose=False) | |
| db = SessionLocal() | |
| try: | |
| seed_demo_questions_if_empty(db) | |
| seed_concept_questions(db) | |
| finally: | |
| db.close() | |
| init_state() | |
| inject_pwa_assets() | |
| apply_mobile_styles() | |
| render_top_bar() | |
| exams = get_exams() | |
| page = st.session_state.page | |
| if page == "ํ": | |
| render_home(exams) | |
| return | |
| render_back_home() | |
| if page == "๊ฐ๋ ๊ณต๋ถ": | |
| render_concept_mode_home() | |
| return | |
| if page == "์ค์ต": | |
| render_practice_mode_home() | |
| return | |
| if page == "์ํ์ค๋น": | |
| render_exam_prep_home(exams) | |
| return | |
| if page == "๋์๋ณด๋": | |
| render_dashboard(exams) | |
| return | |
| if page in {"Daily", "์ด์ด์ ๊ณต๋ถ", "Daily Mode", "์ค๋ ํ์ต ์ธ์ "}: | |
| render_continue_study() | |
| return | |
| if page in {"Focus", "Focus Mode"}: | |
| render_focus_mode() | |
| return | |
| if page in {"Exam", "Exam Mode"}: | |
| render_exam_study_mode() | |
| return | |
| if page in learning_landing_routes(): | |
| route = learning_landing_routes()[page] | |
| if page in {"์ํ์ค๋น", "์ํ ์ค๋น"}: | |
| route(exams) | |
| else: | |
| route() | |
| return | |
| if page == "๋ก๋๋งต": | |
| render_roadmap() | |
| return | |
| if page == "์ด๋ก ํ์ต": | |
| render_theory_learning() | |
| return | |
| if page == "ํ์ธ ํด์ฆ": | |
| render_learning_quiz() | |
| return | |
| if page == "์ํ ๋ชจ๋": | |
| render_exam_mode() | |
| return | |
| if page == "์ค์ตํ๊ธฐ": | |
| render_lab_practice() | |
| return | |
| if page == "์ง๋์จ": | |
| render_progress() | |
| return | |
| if page == "์ฝํ ์ธ ๊ด๋ฆฌ": | |
| render_content_management() | |
| return | |
| selected_exam, selected_source = render_exam_selector(exams) | |
| if page == "์ํ ํํฉ": | |
| render_exam_overview(exams, selected_exam) | |
| elif page in {"์ฒ๋ฆฌ ํํฉ", "์๋ ์ ๋ฆฌ ํํฉ", "๋ฌธ์ ๊ฒ์"}: | |
| render_review(selected_source) | |
| elif page in {"๋ฌธ์ ํ์ด", "์๊ฒฉ์ฆ ๋ฌธ์ "}: | |
| render_quiz(selected_source) | |
| elif page in {"์ทจ์ฝ ๊ฐ๋ ํ์ต", "์ทจ์ฝ ์ ํ ํ์ต"}: | |
| render_weak_quiz(selected_source) | |
| elif page in {"๊ฐ์ ๋จ์ ํ์ต", "๋น์ทํ ์ ํ ํ์ต"}: | |
| render_similar_quiz() | |
| elif page in {"์ค๋ต/๋ณต์ต", "์ค๋ต๋ ธํธ"}: | |
| render_notes(selected_source) | |
| elif page == "๊ฐ๋ ์ ๋ฆฌ": | |
| render_concept_notes(selected_source) | |
| elif page == "AI ์์ธ": | |
| render_vector_index() | |
| elif page == "ํ์ฑ ์์ ์ํ": | |
| render_review(selected_source) | |
| else: | |
| render_upload(exams) | |
| if __name__ == "__main__": | |
| main() | |