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