cert-study-app / cert_study_app /ui /learning_lab.py
Kentlo's picture
Sync from GitHub b098d17dbed1a6386c212d28b4bfa6138ad59d75
88c7275 verified
Raw
History Blame Contribute Delete
15.7 kB
import streamlit as st
from sqlalchemy import func
from cert_study_app.config import DEFAULT_USER
from cert_study_app.db import SessionLocal
from cert_study_app.models import Question
from cert_study_app.services.docs_source_service import doc_source_by_id
from cert_study_app.services.learning_lab_service import (
PRACTICE_TASKS,
active_tracks,
certification_for_track,
evaluate_lab_quiz_detail,
evaluate_practice_detail,
lessons_for_track,
quizzes_for_track,
roadmap_for_track,
track_by_id,
track_progress,
)
from cert_study_app.services.learning_progress_service import (
lab_spaced_review_due_today,
mark_learning_step,
record_activity,
save_completed_items,
save_wrong_notes,
study_units,
update_lab_spaced_review,
update_spaced_review,
)
from cert_study_app.services.quiz_service import QuizService
from cert_study_app.services.question_concept_service import concept_label
from cert_study_app.ui.common import go_to, selected_lab_track
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_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
raw_index = st.session_state.lab_lesson_index
index = min(raw_index, len(lessons) - 1)
lesson = lessons[index]
with st.container(border=True):
_level_badge = {"์ž…๋ฌธ": "๐ŸŸข ์ž…๋ฌธ", "์ค‘๊ธ‰": "๐ŸŸก ์ค‘๊ธ‰", "๊ณ ๊ธ‰": "๐Ÿ”ด ๊ณ ๊ธ‰"}.get(lesson.level, lesson.level)
done_chip = "<span class='cert-chip cert-chip-done'>โœ“ ์™„๋ฃŒ</span>" if lesson.id in completed_lessons else ""
st.caption(_level_badge)
st.markdown(f"### {done_chip} {lesson.title}", unsafe_allow_html=True)
st.caption(f"์นด๋“œ {index + 1}/{len(lessons)} ยท {lesson.track}")
st.write(lesson.summary)
if lesson.details:
with st.expander("์ž์„ธํžˆ ๋ณด๊ธฐ", expanded=False):
for detail in lesson.details:
st.write(detail)
if lesson.example:
with st.expander("์˜ˆ์‹œ", expanded=False):
st.code(lesson.example)
if lesson.common_mistake:
with st.expander("์ž์ฃผ ํ‹€๋ฆฌ๋Š” ํฌ์ธํŠธ", expanded=False):
st.warning(lesson.common_mistake)
if lesson.keywords:
st.caption("ํ‚ค์›Œ๋“œ: " + " ยท ".join(lesson.keywords))
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.success(f"์™„๋ฃŒ ์ฒดํฌ! ์˜ค๋Š˜ ํ™œ๋™ {study_units():.1f}๋‹จ์œ„")
if st.session_state.get("lab_lesson_just_completed") == lesson.id and 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):
st.session_state.lab_lesson_index = max(0, index - 1)
st.rerun()
if next_col.button("๋‹ค์Œ ๋ ˆ์Šจ", use_container_width=True, disabled=index >= len(lessons) - 1):
st.session_state.lab_lesson_index = min(len(lessons) - 1, index + 1)
st.rerun()
if index == len(lessons) - 1 and len(lessons) > 1:
if st.button("ํ™•์ธ ํ€ด์ฆˆ๋กœ ์ด๋™", use_container_width=True):
go_to("ํ™•์ธ ํ€ด์ฆˆ")
def render_learning_quiz():
st.subheader("ํ™•์ธ ํ€ด์ฆˆ")
track_id = selected_lab_track()
track = track_by_id(track_id)
certification = certification_for_track(track_id)
st.caption(f"{track['name']} Track ยท {certification['name']} ๋Œ€๋น„")
quizzes = quizzes_for_track(track_id)
if not quizzes:
st.info("์•„์ง ์ค€๋น„๋œ ํ™•์ธ ํ€ด์ฆˆ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
return
due_ids = set(lab_spaced_review_due_today())
due_quizzes = [q for q in quizzes if q.id in due_ids]
other_quizzes = [q for q in quizzes if q.id not in due_ids]
ordered_quizzes = due_quizzes + other_quizzes
if due_quizzes:
st.info(f"์˜ค๋Š˜ ๋ณต์Šต ์˜ˆ์ • ํ€ด์ฆˆ {len(due_quizzes)}๊ฐœ๊ฐ€ ์•ž์— ๋ฐฐ์น˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
index = min(st.session_state.lab_quiz_index, len(ordered_quizzes) - 1)
quiz = ordered_quizzes[index]
is_due = quiz.id in due_ids
with st.container(border=True):
badge = "๐Ÿ” ๋ณต์Šต" if is_due else quiz.difficulty
st.caption(f"{quiz.track} ยท {quiz.question_type} ยท {badge}")
st.markdown(f"### ๋ฌธ์ œ {index + 1}/{len(ordered_quizzes)}")
st.write(quiz.question)
if quiz.question_type == "multiple_choice":
answer = st.radio("๋‹ต", quiz.options, key=f"lab_quiz_answer_{quiz.id}")
else:
answer = st.text_input("๋ช…๋ น์–ด ์ž…๋ ฅ", key=f"lab_quiz_answer_{quiz.id}", placeholder="๋ช…๋ น์–ด๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”")
source = doc_source_by_id(quiz.source_id)
if source:
st.markdown(f"์ถœ์ฒ˜: [{source.provider} ยท {source.title}]({source.url})")
if st.button("์ •๋‹ต ํ™•์ธ", type="primary", use_container_width=True):
record_activity(track_id, "quiz", 1)
correct, detail_tokens = evaluate_lab_quiz_detail(quiz, answer)
if correct:
st.session_state.lab_completed_quizzes.add(quiz.id)
save_completed_items(
st.session_state.lab_completed_lessons,
st.session_state.lab_completed_quizzes,
st.session_state.lab_completed_practices,
)
mark_learning_step(track_id, "quiz")
st.success("์ •๋‹ต์ž…๋‹ˆ๋‹ค.")
else:
if quiz.question_type == "command" and len(detail_tokens) > 1:
parts_html = " ".join(
f'<span style="color:{"green" if ok else "red"};font-weight:bold">{tok}</span>'
for tok, ok in detail_tokens
)
st.error("์˜ค๋‹ต์ž…๋‹ˆ๋‹ค.")
st.markdown(f"**์ •๋‹ต ๋ถ„์„:** {parts_html} ", unsafe_allow_html=True)
st.caption("์ดˆ๋ก์ƒ‰ = ์ž…๋ ฅ๋จ / ๋นจ๊ฐ„์ƒ‰ = ๋ˆ„๋ฝ ๋˜๋Š” ์˜ค๋ฅ˜")
else:
st.error(f"์˜ค๋‹ต์ž…๋‹ˆ๋‹ค. ์ •๋‹ต: `{quiz.answer}`")
wrong_ids = {item["id"] for item in st.session_state.lab_wrong_notes}
if quiz.id not in wrong_ids:
st.session_state.lab_wrong_notes.append({
"id": quiz.id,
"item_type": "quiz",
"track": quiz.track,
"question": quiz.question,
"user_answer": str(answer),
"correct_answer": quiz.answer,
"explanation": quiz.explanation,
})
save_wrong_notes(st.session_state.lab_wrong_notes)
update_lab_spaced_review(quiz.id, correct)
if quiz.question_type == "multiple_choice" and quiz.options:
try:
_db = SessionLocal()
try:
db_q = _db.query(Question).filter(Question.chunk_key == quiz.id).first()
if db_q:
try:
opts = list(quiz.options)
chosen_letter = chr(ord("A") + opts.index(str(answer))) if str(answer) in opts else None
if chosen_letter:
QuizService(_db).answer(db_q.id, chosen_letter, DEFAULT_USER)
except Exception:
pass
update_spaced_review(db_q.id, correct)
finally:
_db.close()
except Exception:
pass
st.markdown('<div class="answer-explanation">', unsafe_allow_html=True)
st.markdown(quiz.explanation)
st.markdown("</div>", unsafe_allow_html=True)
if not correct and quiz.lesson_id:
all_lessons = lessons_for_track(track_id)
lesson_ids = [l.id for l in all_lessons]
if quiz.lesson_id in lesson_ids:
if st.button("์ด ๋ ˆ์Šจ ๋‹ค์‹œ ๋ณด๊ธฐ", key=f"goto_lesson_{quiz.id}"):
st.session_state.lab_lesson_index = lesson_ids.index(quiz.lesson_id)
go_to("์ด๋ก  ํ•™์Šต")
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 render_lab_practice():
st.subheader("์‹ค์Šตํ•˜๊ธฐ")
st.caption("ํ˜„์žฌ๋Š” Docker ํ„ฐ๋ฏธ๋„์ด ์•„๋‹ˆ๋ผ fake terminal simulator์ž…๋‹ˆ๋‹ค.")
track_id = selected_lab_track()
tasks = [task for task in PRACTICE_TASKS if task.track == track_id and task.status == "approved"]
if not tasks:
st.info("์ด Track์˜ ์‹ค์Šต์€ ์•„์ง ์ค€๋น„ ์ค‘์ž…๋‹ˆ๋‹ค. ํ˜„์žฌ fake terminal ์‹ค์Šต์€ Linux / LFCS ์ค‘์‹ฌ์œผ๋กœ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.")
return
index = min(st.session_state.lab_practice_index, len(tasks) - 1)
task = tasks[index]
with st.container(border=True):
st.caption(f"{task.track} ยท {task.difficulty} ยท {task.status}")
st.markdown(f"### {task.title}")
st.write(task.task_description)
command = st.text_input("ํ„ฐ๋ฏธ๋„ ์ž…๋ ฅ", key=f"practice_command_{task.id}", placeholder=task.expected_command)
with st.expander("ํžŒํŠธ", expanded=False):
st.write(task.hint)
if st.button("์‹ค์Šต ์ฑ„์ ", type="primary", use_container_width=True):
all_pass, condition_results = evaluate_practice_detail(task, command)
if all_pass:
st.session_state.lab_completed_practices.add(task.id)
save_completed_items(
st.session_state.lab_completed_lessons,
st.session_state.lab_completed_quizzes,
st.session_state.lab_completed_practices,
)
mark_learning_step(track_id, "apply")
record_activity(track_id, "practice", 1)
st.success(f"์ •๋‹ต์ž…๋‹ˆ๋‹ค. ์˜ค๋Š˜ ํ™œ๋™ {study_units():.1f}๋‹จ์œ„")
if task.takeaway:
st.info(f"ํ•ต์‹ฌ ํฌ์ธํŠธ: {task.takeaway}")
else:
st.error("์•„์ง ์กฐ๊ฑด์„ ๋งŒ์กฑํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.")
for cond, ok in condition_results:
icon = "โœ…" if ok else "โŒ"
st.markdown(f"{icon} `{cond}`")
st.markdown('<div class="answer-explanation">', unsafe_allow_html=True)
st.markdown(task.explanation)
st.markdown("</div>", unsafe_allow_html=True)
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()