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(
"""
""",
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(
"""
""",
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"""
{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"""
""",
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('학습 모드
', 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 = "✓ 완료" 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)
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'{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]
# 전체 실습 진도 표시
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('', unsafe_allow_html=True)
st.markdown(task.explanation)
st.markdown("
", 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('', unsafe_allow_html=True)
st.markdown(result["explanation"])
st.markdown("
", 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()