sugitora
AI้ขๆŽฅใ‚ทใ‚นใƒ†ใƒ  - ๅˆๅ›žใƒชใƒชใƒผใ‚น (Streamlit + Claude API)
6d1fe52
"""
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()