Spaces:
Running
Running
| 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() | |