Kentlo's picture
Sync from GitHub b098d17dbed1a6386c212d28b4bfa6138ad59d75
88c7275 verified
Raw
History Blame Contribute Delete
15.5 kB
import os
import re
from pathlib import Path
import streamlit as st
from cert_study_app.config import DEFAULT_USER
from cert_study_app.db import SessionLocal
from cert_study_app.services.concept_note_service import ConceptNoteService
from cert_study_app.services.learning_progress_service import (
mark_learning_step,
record_activity,
save_wrong_notes,
study_units,
save_preferred_track,
)
from cert_study_app.services.study_assistant_service import StudyAssistantService
from cert_study_app.services.vector_service import QuestionVectorStore
from cert_study_app.ui.common import (
DEFAULT_DEEP_MODEL,
DEFAULT_EMBEDDING_MODEL,
DEFAULT_FAST_MODEL,
DEFAULT_MAIN_MODEL,
EMBEDDING_MODEL_OPTIONS,
get_service,
go_to,
track_for_question_source,
)
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_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 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 = SessionLocal()
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_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()