Spaces:
Sleeping
Sleeping
| """ | |
| AI้ขๆฅใทในใใ - Streamlit Web UI | |
| =================================== | |
| streamlit run app.py | |
| """ | |
| import io | |
| import os | |
| import sys | |
| import tempfile | |
| import wave | |
| from datetime import datetime | |
| import numpy as np | |
| import plotly.graph_objects as go | |
| import streamlit as st | |
| # ใใญใธใงใฏใใซใผใใใในใซ่ฟฝๅ | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from config import Config, load_config | |
| from csv_loader import load_questions | |
| from evaluator import ClaudeEvaluator, evaluate_answer | |
| from models import Answer, InterviewReport, Question, QuestionResult | |
| from reporter import ReportGenerator | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # HuggingFace Spaces ็ฐๅขๆคๅบ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| IS_HF_SPACE = os.getenv("SPACE_ID") is not None | |
| OUTPUT_DIR = "/tmp/interview_output" if IS_HF_SPACE else "output" | |
| # Whisper/WebRTC ใๅฉ็จๅฏ่ฝใ๏ผHFใงใฏใคใณในใใผใซใใใชใๅ ดๅใใ๏ผ | |
| HAS_WHISPER = False | |
| try: | |
| import whisper | |
| HAS_WHISPER = True | |
| except ImportError: | |
| pass | |
| HAS_WEBRTC = False | |
| try: | |
| from streamlit_webrtc import webrtc_streamer, WebRtcMode | |
| HAS_WEBRTC = True | |
| except ImportError: | |
| pass | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ใใผใธ่จญๅฎ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| st.set_page_config( | |
| page_title="AI ้ขๆฅใทในใใ ", | |
| page_icon="๐๏ธ", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ใซในใฟใ CSS | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| st.markdown(""" | |
| <style> | |
| /* ๅ จไฝ */ | |
| .main .block-container { max-width: 1100px; padding-top: 2rem; } | |
| /* ใใใใผ */ | |
| .app-header { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #2d6a9f 100%); | |
| color: white; padding: 1.5rem 2rem; border-radius: 12px; | |
| margin-bottom: 1.5rem; | |
| } | |
| .app-header h1 { margin: 0; font-size: 1.8rem; } | |
| .app-header p { margin: 0.3rem 0 0 0; opacity: 0.85; font-size: 0.95rem; } | |
| /* ่ณชๅใซใผใ */ | |
| .question-card { | |
| background: #f8fafc; border-left: 4px solid #2d6a9f; | |
| padding: 1.2rem 1.5rem; border-radius: 0 8px 8px 0; | |
| margin: 1rem 0; font-size: 1.1rem; | |
| } | |
| /* ในใณใขใใใธ */ | |
| .score-badge { | |
| display: inline-block; padding: 0.3rem 0.8rem; | |
| border-radius: 20px; font-weight: 600; font-size: 0.9rem; | |
| } | |
| .score-high { background: #dcfce7; color: #166534; } | |
| .score-mid { background: #fef9c3; color: #854d0e; } | |
| .score-low { background: #fee2e2; color: #991b1b; } | |
| /* ้ฒๆใใผๅคๆ */ | |
| .progress-outer { | |
| background: #e2e8f0; border-radius: 999px; height: 8px; | |
| overflow: hidden; margin: 0.5rem 0; | |
| } | |
| .progress-inner { | |
| height: 100%; border-radius: 999px; | |
| background: linear-gradient(90deg, #2d6a9f, #38bdf8); | |
| transition: width 0.4s ease; | |
| } | |
| /* ในใใใใคใณใธใฑใผใฟ */ | |
| .step-indicator { | |
| display: flex; justify-content: center; gap: 0.5rem; | |
| margin: 1rem 0; | |
| } | |
| .step-dot { | |
| width: 12px; height: 12px; border-radius: 50%; | |
| background: #cbd5e1; | |
| } | |
| .step-dot.active { background: #2d6a9f; } | |
| .step-dot.done { background: #22c55e; } | |
| /* ในใใผใฟในใซใผใ */ | |
| .status-card { | |
| border-radius: 12px; padding: 1.5rem; text-align: center; | |
| font-size: 1.1rem; | |
| } | |
| .status-pass { background: #dcfce7; border: 2px solid #22c55e; } | |
| .status-fail { background: #fee2e2; border: 2px solid #ef4444; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Session State ๅๆๅ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def init_session_state(): | |
| defaults = { | |
| "page": "setup", # setup / interview / result / history | |
| "config": None, | |
| "questions": [], | |
| "current_q_idx": 0, | |
| "results": [], | |
| "candidate_name": "", | |
| "report": None, | |
| "input_mode": "text", # text / audio | |
| "history": [], # ้ๅปใฎ้ขๆฅ็ตๆ | |
| } | |
| for k, v in defaults.items(): | |
| if k not in st.session_state: | |
| st.session_state[k] = v | |
| init_session_state() | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ใฆใผใใฃใชใใฃ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def score_class(score: float, max_score: float) -> str: | |
| pct = score / max_score if max_score else 0 | |
| if pct >= 0.7: | |
| return "score-high" | |
| elif pct >= 0.4: | |
| return "score-mid" | |
| return "score-low" | |
| def transcribe_audio_bytes(audio_bytes: bytes, config: Config) -> str: | |
| """ใใฉใฆใถใใๅใๅใฃใWAV้ณๅฃฐใWhisperใงๆธใ่ตทใใ""" | |
| if not HAS_WHISPER: | |
| raise RuntimeError("Whisper ใๅฉ็จใงใใพใใใใใญในใๅ ฅๅใไฝฟ็จใใฆใใ ใใใ") | |
| model = whisper.load_model(config.whisper_model) | |
| with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: | |
| tmp.write(audio_bytes) | |
| tmp_path = tmp.name | |
| try: | |
| result = model.transcribe(tmp_path, language="ja") | |
| return result["text"].strip() | |
| finally: | |
| os.unlink(tmp_path) | |
| def build_radar_chart(results: list[QuestionResult]) -> go.Figure: | |
| """ใซใใดใชๅฅใฌใผใใผใใฃใผใ""" | |
| categories: dict[str, list[QuestionResult]] = {} | |
| for r in results: | |
| categories.setdefault(r.question.category, []).append(r) | |
| labels, values = [], [] | |
| for cat, rs in categories.items(): | |
| total = sum(r.total_score for r in rs) | |
| mx = sum(r.question.max_score for r in rs) | |
| labels.append(cat) | |
| values.append(round(total / mx * 100 if mx else 0, 1)) | |
| # ้ใใ | |
| labels_closed = labels + [labels[0]] | |
| values_closed = values + [values[0]] | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatterpolar( | |
| r=values_closed, theta=labels_closed, | |
| fill='toself', fillcolor='rgba(45,106,159,0.15)', | |
| line=dict(color='#2d6a9f', width=2), | |
| marker=dict(size=6), | |
| )) | |
| fig.update_layout( | |
| polar=dict( | |
| radialaxis=dict(visible=True, range=[0, 100], ticksuffix="%"), | |
| ), | |
| showlegend=False, | |
| margin=dict(l=60, r=60, t=40, b=40), | |
| height=350, | |
| ) | |
| return fig | |
| def build_bar_chart(results: list[QuestionResult]) -> go.Figure: | |
| """่ณชๅๅฅในใณใขๆฃใฐใฉใ""" | |
| labels = [f"Q{r.question.id}" for r in results] | |
| kw_scores = [r.keyword_score for r in results] | |
| ai_scores = [r.ai_content_score for r in results] | |
| imp_scores = [r.improvisation_score for r in results] | |
| max_scores = [r.question.max_score for r in results] | |
| fig = go.Figure() | |
| fig.add_trace(go.Bar(name="ใญใผใฏใผใ", x=labels, y=kw_scores, | |
| marker_color="#38bdf8")) | |
| fig.add_trace(go.Bar(name="ๅ ๅฎน่ฉไพก", x=labels, y=ai_scores, | |
| marker_color="#2d6a9f")) | |
| fig.add_trace(go.Bar(name="ๅณ่ๅ", x=labels, y=imp_scores, | |
| marker_color="#7dd3fc")) | |
| fig.add_trace(go.Scatter( | |
| name="ๆบ็น", x=labels, y=max_scores, | |
| mode="lines+markers", line=dict(color="#ef4444", dash="dash"), | |
| )) | |
| fig.update_layout( | |
| barmode="stack", height=350, | |
| margin=dict(l=40, r=40, t=40, b=40), | |
| legend=dict(orientation="h", yanchor="bottom", y=1.02), | |
| yaxis_title="ในใณใข", | |
| ) | |
| return fig | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ใตใคใใใผ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def render_sidebar(): | |
| with st.sidebar: | |
| st.markdown("### ๐๏ธ AI ้ขๆฅใทในใใ ") | |
| st.divider() | |
| page = st.session_state.page | |
| labels = { | |
| "setup": "โ๏ธ ่จญๅฎ", | |
| "interview": "๐ค ้ขๆฅไธญ", | |
| "result": "๐ ็ตๆ", | |
| "history": "๐ ๅฑฅๆญด", | |
| } | |
| for key, label in labels.items(): | |
| disabled = (key == "interview" and page == "setup") | |
| if key == "result" and st.session_state.report is None: | |
| disabled = True | |
| if st.button(label, key=f"nav_{key}", use_container_width=True, | |
| disabled=disabled): | |
| st.session_state.page = key | |
| st.rerun() | |
| st.divider() | |
| # ้ขๆฅไธญใฎ้ฒๆ่กจ็คบ | |
| if page == "interview" and st.session_state.questions: | |
| total = len(st.session_state.questions) | |
| current = st.session_state.current_q_idx | |
| done = len(st.session_state.results) | |
| st.markdown(f"**้ฒๆ: {done}/{total}**") | |
| pct = done / total * 100 if total else 0 | |
| st.markdown(f""" | |
| <div class="progress-outer"> | |
| <div class="progress-inner" style="width:{pct}%"></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown(f"**ๅ่ฃ่ :** {st.session_state.candidate_name}") | |
| st.divider() | |
| st.caption("v1.0 โ Streamlit UI") | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ใใผใธ: ่จญๅฎ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def page_setup(): | |
| st.markdown("## ๐๏ธ AI ้ขๆฅใทในใใ ") | |
| st.caption("้ณๅฃฐ or ใใญในใใงๅ่ฃ่ ใซใคใณใฟใใฅใผ โ AI ใ่ชๅ่ฉไพกใปในใณใขใชใณใฐ") | |
| col1, col2 = st.columns([3, 2]) | |
| with col1: | |
| st.subheader("๐ค ๅ่ฃ่ ๆ ๅ ฑ") | |
| candidate_name = st.text_input( | |
| "ๅ่ฃ่ ๅ", value=st.session_state.candidate_name, | |
| placeholder="ไพ: ๅฑฑ็ฐๅคช้" | |
| ) | |
| st.subheader("๐ ่ณชๅใใกใคใซ") | |
| upload_tab, default_tab = st.tabs(["CSVใขใใใญใผใ", "ใใใฉใซใไฝฟ็จ"]) | |
| with upload_tab: | |
| uploaded = st.file_uploader( | |
| "่ณชๅCSV ใใขใใใญใผใ", type=["csv"], | |
| help="id, category, question_text, expected_keywords, keyword_weight, ai_weight, improv_weight, max_score, scoring_criteria, follow_up" | |
| ) | |
| with default_tab: | |
| st.info("๐ `data/questions.csv` ใไฝฟ็จใใพใ๏ผ5ๅ๏ผ") | |
| with col2: | |
| st.subheader("โ๏ธ ่จญๅฎ") | |
| if HAS_WEBRTC and HAS_WHISPER: | |
| input_mode = st.radio( | |
| "ๅ ฅๅๆนๅผ", | |
| ["ใใญในใๅ ฅๅ", "้ณๅฃฐๅ ฅๅ๏ผใใคใฏ๏ผ"], | |
| help="ใใญในใๅ ฅๅใฏใใฉใฆใถใ ใใงๅไฝใใพใ" | |
| ) | |
| else: | |
| input_mode = "ใใญในใๅ ฅๅ" | |
| st.info("๐ ใใญในใๅ ฅๅใขใผใ" + ("๏ผHuggingFace Spaces ็ฐๅข๏ผ" if IS_HF_SPACE else "")) | |
| pass_threshold = st.slider( | |
| "ๅๆ ผใฉใคใณ๏ผ%๏ผ", 30, 100, 60, step=5 | |
| ) | |
| if HAS_WHISPER: | |
| whisper_model = st.selectbox( | |
| "Whisperใขใใซ", | |
| ["tiny", "base", "small", "medium"], | |
| index=1, | |
| help="ๅคงใใใขใใซใปใฉ็ฒพๅบฆใ้ซใใ้ ใ" | |
| ) | |
| else: | |
| whisper_model = "base" | |
| claude_model = st.selectbox( | |
| "Claude ใขใใซ", | |
| ["claude-sonnet-4-20250514", "claude-haiku-4-20250414"], | |
| index=0, | |
| ) | |
| st.divider() | |
| # ่ณชๅใใฌใใฅใผ | |
| try: | |
| if uploaded: | |
| tmp_dir = tempfile.mkdtemp() | |
| tmp_path = os.path.join(tmp_dir, "uploaded.csv") | |
| with open(tmp_path, "wb") as f: | |
| f.write(uploaded.getvalue()) | |
| questions = load_questions(tmp_path) | |
| else: | |
| questions = load_questions("data/questions.csv") | |
| st.subheader(f"๐ ่ณชๅไธ่ฆง๏ผ{len(questions)}ๅ๏ผ") | |
| for q in questions: | |
| with st.expander(f"Q{q.id}. [{q.category}] {q.question_text[:50]}..."): | |
| st.markdown(f"**่ณชๅ:** {q.question_text}") | |
| st.markdown(f"**ใญใผใฏใผใ:** {', '.join(q.expected_keywords)}") | |
| st.markdown(f"**้ ็น:** {q.max_score}็น " | |
| f"(KW:{q.keyword_weight} / AI:{q.ai_weight} / ๅณ่:{q.improv_weight})") | |
| st.markdown(f"**่ฉไพกๅบๆบ:** {q.scoring_criteria}") | |
| if q.follow_up: | |
| st.markdown(f"**่ฟฝๅ ่ณชๅ:** {q.follow_up}") | |
| except Exception as e: | |
| st.error(f"่ณชๅใใกใคใซใฎ่ชญใฟ่พผใฟใจใฉใผ: {e}") | |
| questions = [] | |
| st.divider() | |
| # ้ขๆฅ้ๅงใใฟใณ | |
| col_start, _ = st.columns([1, 3]) | |
| with col_start: | |
| can_start = bool(candidate_name.strip() and questions) | |
| if st.button("๐ ้ขๆฅใ้ๅง", type="primary", use_container_width=True, | |
| disabled=not can_start): | |
| # APIใญใผๅๅพ: .env โ ็ฐๅขๅคๆฐ โ st.secrets ใฎ้ ใงๆค็ดข | |
| api_key = "" | |
| try: | |
| config = load_config() | |
| api_key = config.anthropic_api_key | |
| except ValueError: | |
| api_key = os.getenv("ANTHROPIC_API_KEY", "") | |
| if not api_key: | |
| try: | |
| api_key = st.secrets["ANTHROPIC_API_KEY"] | |
| except (KeyError, FileNotFoundError): | |
| pass | |
| if not api_key: | |
| st.error( | |
| "ANTHROPIC_API_KEY ใ่จญๅฎใใใฆใใพใใใ\n\n" | |
| "- **ใญใผใซใซ:** `.env` ใใกใคใซใซ่จญๅฎ\n" | |
| "- **HuggingFace:** Settings โ Secrets ใง่จญๅฎ" | |
| ) | |
| return | |
| config = Config(anthropic_api_key=api_key) | |
| config.pass_threshold = pass_threshold / 100 | |
| config.whisper_model = whisper_model | |
| config.claude_model = claude_model | |
| config.output_dir = OUTPUT_DIR | |
| config.dry_run = True # Web UIใงใฏdry_runๆฑใ | |
| st.session_state.config = config | |
| st.session_state.questions = questions | |
| st.session_state.candidate_name = candidate_name.strip() | |
| st.session_state.current_q_idx = 0 | |
| st.session_state.results = [] | |
| st.session_state.report = None | |
| st.session_state.input_mode = "text" if "ใใญในใ" in input_mode else "audio" | |
| st.session_state.page = "interview" | |
| st.rerun() | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ใใผใธ: ้ขๆฅไธญ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def page_interview(): | |
| config = st.session_state.config | |
| questions = st.session_state.questions | |
| idx = st.session_state.current_q_idx | |
| total = len(questions) | |
| if idx >= total: | |
| _finalize_interview() | |
| return | |
| question = questions[idx] | |
| # โโ ใใใใผ โโ | |
| st.markdown(f"## ๐ค ้ขๆฅไธญ โ {st.session_state.candidate_name}") | |
| st.caption(f"่ณชๅ {idx + 1} / {total}") | |
| # โโ ้ฒๆใใผ โโ | |
| st.progress((idx) / total, text=f"{idx}/{total} ๅฎไบ") | |
| st.divider() | |
| # โโ ่ณชๅ่กจ็คบ โโ | |
| st.markdown(f"### ๐ ่ณชๅ {idx + 1}ใ`{question.category}`") | |
| st.info(f"**{question.question_text}**") | |
| # ใญใผใฏใผใใฏ้ขๆฅๅฎ็จ๏ผใใใฉใซใ้่กจ็คบ๏ผ | |
| with st.expander("๐ ่ฉไพกใญใผใฏใผใ๏ผ้ขๆฅๅฎ็จ๏ผ", expanded=False): | |
| st.write("ใ".join(question.expected_keywords)) | |
| st.caption(f"้ ็น: {question.max_score}็น (KW:{question.keyword_weight} / AI:{question.ai_weight} / ๅณ่:{question.improv_weight})") | |
| st.divider() | |
| # โโ ๅ็ญๅ ฅๅ โโ | |
| st.markdown("### ๐ฌ ๅ็ญ") | |
| if st.session_state.input_mode == "audio": | |
| _audio_input_section(config, question, idx, total) | |
| else: | |
| _text_input_section(config, question, idx, total) | |
| def _text_input_section(config: Config, question: Question, idx: int, total: int): | |
| """ใใญในใๅ ฅๅใขใผใ""" | |
| answer_key = f"answer_text_{idx}" | |
| answer_text = st.text_area( | |
| "ๅ็ญใๅ ฅๅใใฆใใ ใใ", | |
| height=150, | |
| key=answer_key, | |
| placeholder="ใใใซๅ็ญใๅ ฅๅ...", | |
| ) | |
| col_submit, col_skip, col_abort = st.columns([2, 1, 1]) | |
| with col_submit: | |
| if st.button("โ ๅ็ญใ้ไฟก", type="primary", use_container_width=True, | |
| disabled=not answer_text.strip()): | |
| _process_answer(config, question, answer_text.strip(), idx, total) | |
| with col_skip: | |
| if st.button("โญ๏ธ ในใญใใ", use_container_width=True): | |
| _process_answer(config, question, "๏ผๅ็ญใชใ๏ผ", idx, total) | |
| with col_abort: | |
| if st.button("๐ ไธญๆญ", use_container_width=True): | |
| st.session_state.page = "setup" | |
| st.rerun() | |
| def _audio_input_section(config: Config, question: Question, idx: int, total: int): | |
| """้ณๅฃฐๅ ฅๅใขใผใ๏ผstreamlit-webrtc๏ผ""" | |
| if not HAS_WEBRTC: | |
| st.warning("streamlit-webrtc ใๅฉ็จใงใใพใใใใใญในใๅ ฅๅใซๅใๆฟใใพใใ") | |
| st.session_state.input_mode = "text" | |
| st.rerun() | |
| return | |
| st.info("๐ค ใSTARTใใๆผใใฆ่ฉฑใใฆใใ ใใ โ ใSTOPใใง็ตไบ โ ใๅ็ญใ้ไฟกใ") | |
| # WebRTC ้ฒ้ณ | |
| webrtc_ctx = webrtc_streamer( | |
| key=f"recorder_{idx}", | |
| mode=WebRtcMode.SENDONLY, | |
| audio_receiver_size=4096, | |
| media_stream_constraints={"video": False, "audio": True}, | |
| rtc_configuration={"iceServers": [{"urls": ["stun:stun.l.google.com:19302"]}]}, | |
| ) | |
| # ้ฒ้ณใใผใฟๅ้ | |
| audio_buffer_key = f"audio_buffer_{idx}" | |
| if audio_buffer_key not in st.session_state: | |
| st.session_state[audio_buffer_key] = [] | |
| if webrtc_ctx.audio_receiver: | |
| try: | |
| audio_frames = webrtc_ctx.audio_receiver.get_frames(timeout=1) | |
| for frame in audio_frames: | |
| sound = frame.to_ndarray() | |
| st.session_state[audio_buffer_key].append(sound) | |
| except Exception: | |
| pass | |
| # ใใญในใใใฉใผใซใใใฏ | |
| st.divider() | |
| st.markdown("**ใพใใฏใใใญในใใงๅ ฅๅ:**") | |
| answer_text = st.text_area("ๅ็ญใใญในใ", height=100, key=f"audio_fallback_{idx}") | |
| col_submit, col_skip, col_abort = st.columns([2, 1, 1]) | |
| with col_submit: | |
| if st.button("โ ๅ็ญใ้ไฟก", type="primary", use_container_width=True): | |
| text = answer_text.strip() | |
| # ้ณๅฃฐใใใใกใใใใฐWhisperใงๆธใ่ตทใใ | |
| if st.session_state[audio_buffer_key] and not text: | |
| with st.spinner("๐ ้ณๅฃฐใๆธใ่ตทใใไธญ..."): | |
| try: | |
| all_audio = np.concatenate(st.session_state[audio_buffer_key]) | |
| # WAVๅ | |
| buf = io.BytesIO() | |
| with wave.open(buf, "wb") as wf: | |
| wf.setnchannels(1) | |
| wf.setsampwidth(2) | |
| wf.setframerate(48000) | |
| audio_int16 = (all_audio * 32767).astype(np.int16) | |
| wf.writeframes(audio_int16.tobytes()) | |
| text = transcribe_audio_bytes(buf.getvalue(), config) | |
| st.success(f"๐ ๆธใ่ตทใใ: {text}") | |
| except Exception as e: | |
| st.error(f"ๆธใ่ตทใใใจใฉใผ: {e}") | |
| text = "" | |
| if text: | |
| st.session_state[audio_buffer_key] = [] | |
| _process_answer(config, question, text, idx, total) | |
| else: | |
| st.warning("ๅ็ญใ็ฉบใงใใใใญในใๅ ฅๅใใใใ้ณๅฃฐใ้ฒ้ณใใฆใใ ใใใ") | |
| with col_skip: | |
| if st.button("โญ๏ธ ในใญใใ", use_container_width=True, key=f"skip_audio_{idx}"): | |
| st.session_state[audio_buffer_key] = [] | |
| _process_answer(config, question, "๏ผๅ็ญใชใ๏ผ", idx, total) | |
| with col_abort: | |
| if st.button("๐ ไธญๆญ", use_container_width=True, key=f"abort_audio_{idx}"): | |
| st.session_state.page = "setup" | |
| st.rerun() | |
| def _process_answer(config: Config, question: Question, text: str, idx: int, total: int): | |
| """ๅ็ญใ่ฉไพกใใฆๆฌกใธ้ฒใ""" | |
| answer = Answer(transcribed_text=text, audio_duration_sec=0.0) | |
| claude = ClaudeEvaluator( | |
| api_key=config.anthropic_api_key, | |
| model=config.claude_model, | |
| ) | |
| with st.spinner("๐ค AI ใ่ฉไพกไธญ..."): | |
| result = evaluate_answer(question, answer, claude) | |
| st.session_state.results.append(result) | |
| # ็ตๆใใคใณใฉใคใณ่กจ็คบ | |
| pct = result.total_score / question.max_score * 100 if question.max_score else 0 | |
| if pct >= 70: | |
| st.success(f"**{result.total_score:.1f} / {question.max_score} ็น ({pct:.0f}%)**") | |
| elif pct >= 40: | |
| st.warning(f"**{result.total_score:.1f} / {question.max_score} ็น ({pct:.0f}%)**") | |
| else: | |
| st.error(f"**{result.total_score:.1f} / {question.max_score} ็น ({pct:.0f}%)**") | |
| st.info(f"๐ฌ {result.ai_feedback}") | |
| # ๆฌกใฎ่ณชๅใธ | |
| st.session_state.current_q_idx = idx + 1 | |
| if idx + 1 >= total: | |
| _finalize_interview() | |
| else: | |
| st.rerun() | |
| def _finalize_interview(): | |
| """้ขๆฅ็ตๆใใพใจใใฆใฌใใผใใ็ๆ""" | |
| config = st.session_state.config | |
| results = st.session_state.results | |
| total_score = sum(r.total_score for r in results) | |
| max_possible = sum(r.question.max_score for r in results) | |
| percentage = total_score / max_possible if max_possible > 0 else 0 | |
| is_passed = percentage >= config.pass_threshold | |
| report = InterviewReport( | |
| candidate_name=st.session_state.candidate_name, | |
| interview_date=datetime.now(), | |
| results=results, | |
| total_score=total_score, | |
| max_possible_score=max_possible, | |
| pass_threshold=config.pass_threshold, | |
| is_passed=is_passed, | |
| ) | |
| # ใใกใคใซไฟๅญ | |
| reporter = ReportGenerator(output_dir=config.output_dir) | |
| reporter.generate(report) | |
| st.session_state.report = report | |
| st.session_state.history.append(report) | |
| st.session_state.page = "result" | |
| st.rerun() | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ใใผใธ: ็ตๆ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def page_result(): | |
| report: InterviewReport = st.session_state.report | |
| if report is None: | |
| st.warning("้ขๆฅ็ตๆใใใใพใใใ") | |
| return | |
| pct = report.total_score / report.max_possible_score * 100 if report.max_possible_score else 0 | |
| # โโ ใใใใผ โโ | |
| st.markdown("## ๐ ้ขๆฅ็ตๆใฌใใผใ") | |
| st.caption(f"{report.candidate_name} โ {report.interview_date.strftime('%Yๅนด%mๆ%dๆฅ %H:%M')}") | |
| # โโ ๅๅฆๅคๅฎใซใผใ โโ | |
| if report.is_passed: | |
| st.success( | |
| f"### โ ๅๆ ผ\n\n" | |
| f"**{report.total_score:.1f} / {report.max_possible_score:.0f} ็น ({pct:.1f}%)**" | |
| f" โ ๅๆ ผใฉใคใณ {report.pass_threshold*100:.0f}%" | |
| ) | |
| else: | |
| st.error( | |
| f"### โ ไธๅๆ ผ\n\n" | |
| f"**{report.total_score:.1f} / {report.max_possible_score:.0f} ็น ({pct:.1f}%)**" | |
| f" โ ๅๆ ผใฉใคใณ {report.pass_threshold*100:.0f}%" | |
| ) | |
| st.markdown("") | |
| # โโ ใใฃใผใ โโ | |
| chart_col1, chart_col2 = st.columns(2) | |
| with chart_col1: | |
| st.markdown("#### ใซใใดใชๅฅใฌใผใใผ") | |
| st.plotly_chart(build_radar_chart(report.results), use_container_width=True) | |
| with chart_col2: | |
| st.markdown("#### ่ณชๅๅฅในใณใขๅ ่จณ") | |
| st.plotly_chart(build_bar_chart(report.results), use_container_width=True) | |
| st.divider() | |
| # โโ ่ณชๅใใจใฎ่ฉณ็ดฐ โโ | |
| st.subheader("๐ ่ณชๅๅฅ ่ฉณ็ดฐ่ฉไพก") | |
| for i, result in enumerate(report.results, start=1): | |
| q = result.question | |
| pct_q = result.total_score / q.max_score * 100 if q.max_score else 0 | |
| cls = score_class(result.total_score, q.max_score) | |
| with st.expander( | |
| f"Q{q.id}. [{q.category}] {q.question_text[:40]}... โ " | |
| f"{result.total_score:.1f}/{q.max_score}็น", | |
| expanded=(i <= 2), | |
| ): | |
| st.markdown(f"**่ณชๅ:** {q.question_text}") | |
| st.markdown(f"**ๅ็ญ:** {result.answer.transcribed_text}") | |
| st.divider() | |
| sc1, sc2, sc3, sc4 = st.columns(4) | |
| sc1.metric("ใญใผใฏใผใ", f"{result.keyword_score:.1f}") | |
| sc2.metric("ๅ ๅฎน่ฉไพก", f"{result.ai_content_score:.1f}") | |
| sc3.metric("ๅณ่ๅ", f"{result.improvisation_score:.1f}") | |
| sc4.metric("ๅ่จ", f"{result.total_score:.1f} / {q.max_score}") | |
| kw_hit = ", ".join(result.keyword_hits) if result.keyword_hits else "ใชใ" | |
| total_kw = len(q.expected_keywords) | |
| hit_kw = len(result.keyword_hits) | |
| st.markdown(f"**ใญใผใฏใผใไธ่ด:** {hit_kw}/{total_kw} ({kw_hit})") | |
| st.info(f"๐ฌ {result.ai_feedback}") | |
| st.divider() | |
| # โโ ใฌใใผใใใฆใณใญใผใ โโ | |
| reporter = ReportGenerator(output_dir=st.session_state.config.output_dir) | |
| report_text = reporter._build_report(report) | |
| col_dl, col_new, _ = st.columns([1, 1, 2]) | |
| with col_dl: | |
| st.download_button( | |
| "๐ฅ ใฌใใผใใใใฆใณใญใผใ", | |
| data=report_text, | |
| file_name=f"interview_{report.candidate_name}_{report.interview_date.strftime('%Y%m%d_%H%M%S')}.txt", | |
| mime="text/plain", | |
| use_container_width=True, | |
| ) | |
| with col_new: | |
| if st.button("๐ ๆฐใใ้ขๆฅใ้ๅง", use_container_width=True): | |
| st.session_state.page = "setup" | |
| st.session_state.current_q_idx = 0 | |
| st.session_state.results = [] | |
| st.session_state.report = None | |
| st.rerun() | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ใใผใธ: ๅฑฅๆญด | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def page_history(): | |
| st.markdown("## ๐ ้ขๆฅๅฑฅๆญด") | |
| st.caption("ใใฎใปใใทใงใณไธญใซๅฎๆฝใใ้ขๆฅใฎไธ่ฆง") | |
| history = st.session_state.history | |
| if not history: | |
| st.info("ใพใ ้ขๆฅใๅฎๆฝใใฆใใพใใใ") | |
| if st.button("โ๏ธ ่จญๅฎ็ป้ขใธ"): | |
| st.session_state.page = "setup" | |
| st.rerun() | |
| return | |
| # ไธ่ฆงใใผใใซ | |
| for i, report in enumerate(reversed(history)): | |
| pct = report.total_score / report.max_possible_score * 100 if report.max_possible_score else 0 | |
| status = "โ ๅๆ ผ" if report.is_passed else "โ ไธๅๆ ผ" | |
| date_str = report.interview_date.strftime('%Y/%m/%d %H:%M') | |
| col_info, col_score, col_btn = st.columns([3, 2, 1]) | |
| with col_info: | |
| st.markdown(f"**{report.candidate_name}**") | |
| st.caption(date_str) | |
| with col_score: | |
| st.markdown(f"{report.total_score:.1f}/{report.max_possible_score:.0f}็น ({pct:.0f}%) {status}") | |
| with col_btn: | |
| if st.button("๐ ่ฉณ็ดฐ", key=f"hist_{i}"): | |
| st.session_state.report = report | |
| st.session_state.page = "result" | |
| st.rerun() | |
| st.divider() | |
| # ๆฏ่ผใใฃใผใ๏ผ2ไปถไปฅไธ๏ผ | |
| if len(history) >= 2: | |
| st.subheader("๐ ๅ่ฃ่ ๆฏ่ผ") | |
| names = [r.candidate_name for r in history] | |
| scores = [r.total_score / r.max_possible_score * 100 if r.max_possible_score else 0 | |
| for r in history] | |
| fig = go.Figure() | |
| colors = ["#22c55e" if r.is_passed else "#ef4444" for r in history] | |
| fig.add_trace(go.Bar(x=names, y=scores, marker_color=colors)) | |
| fig.add_hline( | |
| y=history[0].pass_threshold * 100, | |
| line_dash="dash", line_color="orange", | |
| annotation_text="ๅๆ ผใฉใคใณ", | |
| ) | |
| fig.update_layout( | |
| yaxis_title="ในใณใข (%)", yaxis_range=[0, 100], | |
| height=350, margin=dict(l=40, r=40, t=40, b=40), | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ใกใคใณใซใผใใฃใณใฐ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def main(): | |
| render_sidebar() | |
| page = st.session_state.page | |
| if page == "setup": | |
| page_setup() | |
| elif page == "interview": | |
| page_interview() | |
| elif page == "result": | |
| page_result() | |
| elif page == "history": | |
| page_history() | |
| else: | |
| st.session_state.page = "setup" | |
| st.rerun() | |
| if __name__ == "__main__": | |
| main() | |