diff --git "a/streamlit_app.py" "b/streamlit_app.py" --- "a/streamlit_app.py" +++ "b/streamlit_app.py" @@ -1,7 +1,8 @@ import os import re import json -from datetime import datetime +import random +from datetime import datetime, date, timedelta from pathlib import Path from dotenv import load_dotenv @@ -13,12 +14,50 @@ 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 Question +from cert_study_app.models import Attempt, Question from cert_study_app.services.airflow_service import AirflowService, AirflowTriggerError -from cert_study_app.services.azure_docs_service import AzureDocsService from cert_study_app.services.concept_note_service import ConceptNoteService -from cert_study_app.services.demo_seed_service import seed_demo_questions_if_empty +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, @@ -26,13 +65,18 @@ from cert_study_app.services.question_type_metadata_service import ( status_label, type_metadata, ) -from cert_study_app.services.question_concept_service import classify_question_batch, concept_label +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", page_icon=":books:", layout="wide") +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") @@ -111,6 +155,20 @@ def init_state(): 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(): @@ -213,6 +271,154 @@ def apply_mobile_styles(): 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; } """, unsafe_allow_html=True, @@ -258,32 +464,258 @@ def go_to(page: str): 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): - st.subheader("시작하기") - total_questions = sum(exam["count"] for exam in exams) - st.caption(f"등록된 시험 {len(exams)}개 · 전체 문항 {total_questions}개") + # ── 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 = "시험준비" - if st.button("문제 풀이 시작", type="primary", use_container_width=True): - go_to("문제 풀이") + # ── 완료 축하 (오늘 처음 완료 시 한 번만) ──────────────────── + 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() - st.markdown('
학습
', 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("개념 정리") - if st.button("취약 개념 학습", use_container_width=True): - go_to("취약 개념 학습") + # ── 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""" +
+
{cert_name} 대비
+
{streak_text}
+
+
+
+
{step_label}
+
+ + """, + 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""" +
+
{"✓" if concept_done else "①"}
개념
+
{"✓" if practice_done else "②"}
실습
+
{"✓" if review_done else "③"}
복습
+
+ """, + 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""" +
+
+
{due_value}
+
간격 복습 대기
+
+
+
{units:.1f}
+
오늘 활동 단위
+
+
+ """, + unsafe_allow_html=True, + ) - with st.expander("업로드/관리", expanded=False): - if st.button("PDF 업로드", use_container_width=True): - go_to("PDF 업로드") - if st.button("처리 현황", use_container_width=True): - go_to("처리 현황") - if st.button("시험 현황", use_container_width=True): - go_to("시험 현황") - if st.button("AI 색인", use_container_width=True): - go_to("AI 색인") + 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('
학습 모드
', unsafe_allow_html=True) + + # 1. 개념 공부 + lessons = lessons_for_track(track_id) + quizzes_list = quizzes_for_track(track_id) + c_chip_cls = "cert-chip-done" if concept_done else "cert-chip-active" + c_chip_txt = "✓ 완료" if concept_done else "학습 중" + c_desc = f"이론 {len(lessons)}카드 · 확인 퀴즈 {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"] + if track_id == "azure": + p_title, p_desc, p_page = "📝 문제 적용", "개념을 AZ-104 실전 문제에 바로 적용합니다", "시험준비" + p_chip_txt = "✓ 완료" if practice_done else "학습 중" + elif practices: + p_title = "🖥 실습" + p_desc = f"명령어 실습 과제 {len(practices)}개" + p_page = "실습" + 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(): @@ -291,6 +723,144 @@ def render_back_home(): 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 = "✓ 완료" 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 = "✓ 완료" 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('
시험별 문제 풀이
', 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('
개념 모의시험
', 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('
복습
', 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) @@ -318,6 +888,981 @@ def render_exam_overview(exams, selected_exam): ) +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 + + 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'{tok}' + 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('
', unsafe_allow_html=True) + st.markdown(quiz.explanation) + st.markdown("
", 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] + + 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('
', unsafe_allow_html=True) + st.markdown(task.explanation) + st.markdown("
", unsafe_allow_html=True) + + # 채점 후 다음 실습 바로 이동 + 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(): @@ -360,31 +1905,33 @@ def is_first_group_question(question) -> bool: 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()] - headings = { - "개요", - "일반 개요", - "기존 환경", - "환경", - "요구사항", - "요구 사항", - "계획된 변경", - "기술 요구 사항", - "사용자 요구 사항", - "인증 요구 사항", - "부서 요구 사항", - "네트워크 인프라", - "Active Directory 환경", - "라이센스 문제", - "문제 설명", - } sections = [] title = "요약" body = [] for line in lines: normalized = line.rstrip(":") - is_heading = normalized in headings or ( + is_heading = normalized in PARENT_STEM_HEADINGS or ( len(normalized) <= 24 and any(keyword in normalized for keyword in ["요구", "환경", "개요", "문제"]) ) if is_heading and body: @@ -401,30 +1948,13 @@ def split_parent_sections(text: str) -> list[tuple[str, str]]: def format_parent_stem(text: str) -> str: - headings = { - "개요", - "일반 개요", - "기존 환경", - "환경", - "요구사항", - "요구 사항", - "계획된 변경", - "기술 요구 사항", - "사용자 요구 사항", - "인증 요구 사항", - "부서 요구 사항", - "네트워크 인프라", - "Active Directory 환경", - "라이센스 문제", - "문제 설명", - } 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 headings or ( + is_heading = normalized in PARENT_STEM_HEADINGS or ( len(normalized) <= 24 and any(keyword in normalized for keyword in ["요구", "환경", "개요", "문제"]) ) if is_heading: @@ -1017,13 +2547,23 @@ def render_quiz_controls(service, source, current_question=None): def render_quiz(source=None): db, service = get_service() try: - question = service.get_question(st.session_state.question_id, source) + 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, source, question) + order_mode = render_quiz_controls(service, filtered_source, question) render_question_header(question) render_question_body(question) render_question_image(question) @@ -1035,17 +2575,24 @@ def render_quiz(source=None): st.warning("답을 먼저 선택해 주세요.") else: chosen = str(st.session_state.selected).strip() - st.session_state.last_result = service.answer( - question["id"], - chosen, - DEFAULT_USER, - ) + 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_question(question["id"], source) + 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: @@ -1056,9 +2603,25 @@ def render_quiz(source=None): with next_col: if st.button("다음", use_container_width=True): if order_mode == "랜덤": - next_question = service.get_random_question(source, question["id"]) or {"end": True} + 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: - next_question = service.next_question(question["id"], source) + 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: @@ -1136,7 +2699,10 @@ def render_weak_quiz(source=None): st.warning("답을 먼저 선택해 주세요.") else: chosen = str(st.session_state.selected).strip() - st.session_state.last_result = service.answer(question["id"], chosen, DEFAULT_USER) + 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) @@ -1257,27 +2823,111 @@ def render_similar_quiz(): 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) - st.subheader(f"오답/복습 {payload['count']}개") - 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"]) 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): @@ -1362,8 +3012,8 @@ def render_review(source=None): with st.container(): col1, col2 = st.columns([2, 1]) - concept_overwrite = col1.checkbox("기존 개념 분류도 다시 계산", value=False) - if col2.button("개념 분류 실행", use_container_width=True): + 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, @@ -1371,7 +3021,7 @@ def render_review(source=None): overwrite=concept_overwrite, ) st.success( - f"개념 분류 {concept_summary['checked']}개 · " + f"AZ-104 영역 분류 {concept_summary['checked']}개 · " f"분류됨 {concept_summary['classified']}개 · " f"미분류 {concept_summary['uncategorized']}개" ) @@ -1885,6 +3535,12 @@ def render_quiz_assistant(current_question, source=None): 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("질문을 입력해 주세요.") @@ -1908,40 +3564,44 @@ def render_quiz_assistant(current_question, source=None): f"{options_text or '보기 없음'}\n\n" f"내 질문:\n{prompt}" ) - 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', '')}" + 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 title: - st.markdown(f"**{title}**") - if url: - st.caption(url) - st.write(search_result["text"]) + 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() @@ -1954,7 +3614,7 @@ def render_vector_index(): index=EMBEDDING_MODEL_OPTIONS.index(DEFAULT_EMBEDDING_MODEL) if DEFAULT_EMBEDDING_MODEL in EMBEDDING_MODEL_OPTIONS else 0, - help="한국어 질문과 영어 Azure Docs를 같이 검색하려면 BAAI/bge-m3를 추천합니다.", + help="한국어 질문과 영어 공식 Docs를 같이 검색하려면 BAAI/bge-m3를 추천합니다.", ) st.caption(f"Chroma 컬렉션은 임베딩 모델별로 분리됩니다. 현재 모델: `{embedding_model}`") db, service = SessionLocal(), None @@ -1969,8 +3629,19 @@ def render_vector_index(): st.success(f"{indexed}개 문항을 색인했습니다.") st.divider() - st.markdown("#### Azure Docs") - docs_service = AzureDocsService(db, embedding_model=embedding_model) + 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( @@ -1982,11 +3653,13 @@ def render_vector_index(): if latest_sync.error_message: st.error(latest_sync.error_message[:1000]) else: - st.caption("아직 Azure Docs 색인이 없습니다.") + st.caption("아직 공식 Docs 색인이 없습니다.") st.caption("권장 주기: 분기 1회 · 시험 직전에는 수동 동기화를 한 번 실행하세요.") - docs_limit = st.number_input("동기화할 Azure Docs URL 수", min_value=1, max_value=50, value=12, step=1) - if st.button("Azure Docs 벡터 색인", use_container_width=True): - with st.spinner("Azure Docs를 가져와 Chroma에 색인하는 중입니다. 첫 실행은 모델 다운로드 때문에 오래 걸릴 수 있습니다."): + 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"]) @@ -2048,19 +3721,30 @@ def render_concept_notes(source=None): 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() - st.title("Cert Study") + render_top_bar() exams = get_exams() page = st.session_state.page @@ -2069,19 +3753,69 @@ def main(): 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 == "문제 풀이": + elif page in {"문제 풀이", "자격증 문제"}: render_quiz(selected_source) elif page in {"취약 개념 학습", "취약 유형 학습"}: render_weak_quiz(selected_source) elif page in {"같은 단원 학습", "비슷한 유형 학습"}: render_similar_quiz() - elif page == "오답/복습": + elif page in {"오답/복습", "오답노트"}: render_notes(selected_source) elif page == "개념 정리": render_concept_notes(selected_source)