Spaces:
Sleeping
Sleeping
Nam Fam
commited on
Commit
·
3549ca5
1
Parent(s):
91d5eaf
add files
Browse files- Dockerfile +20 -0
- README.md +3 -0
- app.py +495 -0
- assets/sample_rubric.yaml +18 -0
- requirements.txt +4 -0
Dockerfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.9-slim-buster
|
| 3 |
+
|
| 4 |
+
# Set the working directory in the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy the requirements file into the container at /app
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
|
| 10 |
+
# Install any needed packages specified in requirements.txt
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
# Copy the current directory contents into the container at /app
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Expose the port that Streamlit runs on
|
| 17 |
+
EXPOSE 8501
|
| 18 |
+
|
| 19 |
+
# Run the Streamlit application
|
| 20 |
+
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
README.md
CHANGED
|
@@ -4,6 +4,9 @@ emoji: 📚
|
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: gray
|
| 6 |
sdk: docker
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
|
|
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8501
|
| 8 |
+
tags:
|
| 9 |
+
- streamlit
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
app.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import yaml
|
| 3 |
+
import requests
|
| 4 |
+
import pypdf
|
| 5 |
+
import docx
|
| 6 |
+
import re
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
st.set_page_config(page_title="AI Interview Scorer", page_icon="🤖", layout="wide")
|
| 11 |
+
st.title("Intelcruit: AI Interview Scorer")
|
| 12 |
+
# --- MOCK DATA CONSTANTS (to make frontend self-contained) ---
|
| 13 |
+
MOCK_JOB_DESCRIPTION = """
|
| 14 |
+
Mô tả công việc
|
| 15 |
+
|
| 16 |
+
Sử dụng các công cụ và framework như TensorFlow, PyTorch, và Hugging Face Transformers để xây dựng các mô hình ngôn ngữ.
|
| 17 |
+
Sử dụng các kỹ thuật NLP để phân tích, trích xuất thông tin từ văn bản, và xử lý ngôn ngữ tự nhiên.
|
| 18 |
+
Phát triển các hệ thống truy xuất thông tin từ cơ sở dữ liệu để hỗ trợ quá trình tạo ra câu trả lời chính xác và đầy đủ.
|
| 19 |
+
Sử dụng các kỹ thuật RAG để kết hợp thông tin truy xuất từ các nguồn dữ liệu với khả năng sinh văn bản của mô hình.
|
| 20 |
+
Theo dõi và nghiên cứu các xu hướng và công nghệ mới trong lĩnh vực NLP, Chatbot và RAG.
|
| 21 |
+
Tối ưu hóa thời gian phản hồi và hiệu suất của hệ thống truy xuất thông tin.
|
| 22 |
+
|
| 23 |
+
Yêu cầu ứng viên
|
| 24 |
+
|
| 25 |
+
Có tối thiểu 1 năm kinh nghiệm
|
| 26 |
+
Tốt nghiệp Cao đẳng/Đại học các chuyên ngành Công nghệ Thông tin, Toán Tin, Điện tử Viễn thông, Điều khiển Tự động, hoặc các ngành liên quan.
|
| 27 |
+
Kiến thức chuyên môn:
|
| 28 |
+
Có hiểu biết về Machine Learning và Deep Learning.
|
| 29 |
+
Kinh nghiệm làm việc với các mô hình ngôn ngữ lớn (LLM)
|
| 30 |
+
Có kinh nghiệm làm việc với RESTAPI, Langchain, llamaindex, ...
|
| 31 |
+
Kỹ năng nghiên cứu và nền tảng:
|
| 32 |
+
Khả năng nghiên cứu và áp dụng các công nghệ mới.
|
| 33 |
+
Nền tảng vững chắc về cấu trúc dữ liệu và thuật toán.
|
| 34 |
+
Hiểu biết và có kinh nghiệm lập trình với các ngôn ngữ như C++ và Python.
|
| 35 |
+
Có kinh nghiệm làm việc với cơ sở dữ liệu SQL.
|
| 36 |
+
|
| 37 |
+
Quyền lợi
|
| 38 |
+
|
| 39 |
+
Mức lương: thỏa thuận khi phỏng vấn
|
| 40 |
+
Công ty đóng 100% BHYT, BHXH, BHTN
|
| 41 |
+
Công ty cung cấp thiết bị làm việc
|
| 42 |
+
Review lương 1 - 2 lần/năm theo năng lực
|
| 43 |
+
Thưởng ngày lễ 2/9, 30/04, 1/5, ..., Tết, thưởng lương tháng 13
|
| 44 |
+
Thưởng kết quả kinh doanh toàn công ty cuối năm
|
| 45 |
+
Du lịch 2 lần/năm
|
| 46 |
+
Môi trường làm việc năng động, chuyên nghiệp
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
MOCK_RUBRIC_CONTENT = """expertise:
|
| 52 |
+
description: "Đánh giá mức độ thành thạo về chuyên môn AI, bao gồm kiến thức và kinh nghiệm thực tế với NLP, LLM, RAG, và các công cụ như PyTorch, TensorFlow, HuggingFace, LangChain, REST API. Khả năng áp dụng thuật toán, xử lý dữ liệu và tối ưu hóa hệ thống cũng được xem xét."
|
| 53 |
+
weight: 0.7
|
| 54 |
+
|
| 55 |
+
communication:
|
| 56 |
+
description: "Đánh giá khả năng trình bày ý tưởng rõ ràng, trao đổi kỹ thuật hiệu quả, viết tài liệu hoặc báo cáo kỹ thuật dễ hiểu, và khả năng giao tiếp với các thành viên không chuyên kỹ thuật (PM, khách hàng nội bộ)."
|
| 57 |
+
weight: 0.3
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
# --- MOCK DATA PATHS ---
|
| 63 |
+
# Get the absolute path of the directory containing the current script (frontend/)
|
| 64 |
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 65 |
+
# Go up one level to get the project root (intelcruit/)
|
| 66 |
+
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
|
| 67 |
+
# Construct the full, robust paths to the mock files
|
| 68 |
+
MOCK_AUDIO_PATH = os.path.join(PROJECT_ROOT, "backend", "examples", "example_interview_audio_tts_ai_engineer.wav")
|
| 69 |
+
MOCK_TRANSCRIPT_PATH = os.path.join(PROJECT_ROOT, "backend", "examples", "example_interview_transcipt.txt")
|
| 70 |
+
MOCK_RESUME_PATH = os.path.join(PROJECT_ROOT, "backend", "examples", "example_resume_ai_engineer.pdf")
|
| 71 |
+
MOCK_JD_PATH = os.path.join(PROJECT_ROOT, "backend", "examples", "example_job_description.txt")
|
| 72 |
+
MOCK_RUBRIC_PATH = os.path.join(PROJECT_ROOT, "backend", "examples", "example_rubric.yaml")
|
| 73 |
+
MOCK_TRANSCRIPT_PLACEHOLDER = "This is a placeholder for the mock transcript. It will be replaced by content from the mock file if available."
|
| 74 |
+
|
| 75 |
+
def load_mock_file(path, mime_type):
|
| 76 |
+
"""Loads a mock file from the given path and returns a BytesIO object."""
|
| 77 |
+
if not os.path.exists(path):
|
| 78 |
+
st.warning(f"Mock file not found at: {path}. Please ensure it exists.")
|
| 79 |
+
return None
|
| 80 |
+
with open(path, "rb") as f:
|
| 81 |
+
file_bytes = f.read()
|
| 82 |
+
mock_file = BytesIO(file_bytes)
|
| 83 |
+
mock_file.name = os.path.basename(path)
|
| 84 |
+
mock_file.type = mime_type
|
| 85 |
+
return mock_file
|
| 86 |
+
|
| 87 |
+
# --- API & HELPER FUNCTIONS ---
|
| 88 |
+
def calculate_overall_score(scored_pairs, rubric):
|
| 89 |
+
if not rubric or not scored_pairs:
|
| 90 |
+
return 0, {}
|
| 91 |
+
category_weights = {cat: data.get('weight', 1) for cat, data in rubric.items()}
|
| 92 |
+
total_rubric_weight = sum(category_weights.values())
|
| 93 |
+
if total_rubric_weight == 0:
|
| 94 |
+
return 0, {}
|
| 95 |
+
category_scores = {cat: [] for cat in category_weights.keys()}
|
| 96 |
+
for pair in scored_pairs:
|
| 97 |
+
if 'analysis' in pair and 'scores' in pair['analysis']:
|
| 98 |
+
for score_item in pair['analysis']['scores']:
|
| 99 |
+
category = score_item.get('category')
|
| 100 |
+
score = score_item.get('score')
|
| 101 |
+
if category in category_scores and isinstance(score, (int, float)):
|
| 102 |
+
category_scores[category].append(score)
|
| 103 |
+
avg_category_scores = {}
|
| 104 |
+
for cat, scores in category_scores.items():
|
| 105 |
+
avg_category_scores[cat] = sum(scores) / len(scores) if scores else 0
|
| 106 |
+
weighted_score = sum(avg_score * category_weights.get(cat, 1) for cat, avg_score in avg_category_scores.items())
|
| 107 |
+
final_score_10 = weighted_score / total_rubric_weight
|
| 108 |
+
|
| 109 |
+
# Scale scores to 100
|
| 110 |
+
final_score_100 = final_score_10 * 10
|
| 111 |
+
avg_category_scores_100 = {cat: score * 10 for cat, score in avg_category_scores.items()}
|
| 112 |
+
|
| 113 |
+
return final_score_100, avg_category_scores_100
|
| 114 |
+
|
| 115 |
+
def calculate_resume_overall_score(results):
|
| 116 |
+
"""Calculates the overall resume score from the detailed scores."""
|
| 117 |
+
if not results:
|
| 118 |
+
return 0
|
| 119 |
+
|
| 120 |
+
scores = []
|
| 121 |
+
for category in ['experience', 'education', 'skills']:
|
| 122 |
+
# Safely get the score, defaulting to 0 if not found or not a number
|
| 123 |
+
score = results.get(category, {}).get('score')
|
| 124 |
+
if isinstance(score, (int, float)):
|
| 125 |
+
scores.append(score)
|
| 126 |
+
|
| 127 |
+
if not scores:
|
| 128 |
+
return 0
|
| 129 |
+
|
| 130 |
+
# Calculate the average score
|
| 131 |
+
overall_score = sum(scores) / len(scores)
|
| 132 |
+
return round(overall_score, 1)
|
| 133 |
+
|
| 134 |
+
def display_results(results_data, rubric_content):
|
| 135 |
+
try:
|
| 136 |
+
rubric = yaml.safe_load(rubric_content)
|
| 137 |
+
except yaml.YAMLError:
|
| 138 |
+
st.error("Could not parse rubric.")
|
| 139 |
+
rubric = {}
|
| 140 |
+
# st.header("📊 Analysis Report")
|
| 141 |
+
scored_pairs = results_data.get('results', {}).get('scored_qa_pairs', [])
|
| 142 |
+
if not scored_pairs:
|
| 143 |
+
st.warning("No scorable question and answer pairs were found.")
|
| 144 |
+
return
|
| 145 |
+
st.subheader("Summary")
|
| 146 |
+
final_score, avg_category_scores = calculate_overall_score(scored_pairs, rubric)
|
| 147 |
+
st.metric(label="Overall Score", value=f"{final_score:.1f}/100")
|
| 148 |
+
if avg_category_scores:
|
| 149 |
+
st.markdown("**Category Scores:**")
|
| 150 |
+
cols = st.columns(len(avg_category_scores))
|
| 151 |
+
for i, (cat, score) in enumerate(avg_category_scores.items()):
|
| 152 |
+
with cols[i]:
|
| 153 |
+
st.metric(label=cat.replace('_', ' ').title(), value=f"{score:.1f}/100")
|
| 154 |
+
st.markdown("---")
|
| 155 |
+
st.subheader("Detailed Question & Answer Analysis")
|
| 156 |
+
categorized_scores = {}
|
| 157 |
+
for pair in scored_pairs:
|
| 158 |
+
category = pair.get('category', 'general')
|
| 159 |
+
if category not in categorized_scores:
|
| 160 |
+
categorized_scores[category] = []
|
| 161 |
+
categorized_scores[category].append(pair)
|
| 162 |
+
sorted_categories = sorted(categorized_scores.keys(), key=lambda x: (x == 'general', x))
|
| 163 |
+
for category in sorted_categories:
|
| 164 |
+
pairs = categorized_scores[category]
|
| 165 |
+
# st.subheader(f"Category: {category.replace('_', ' ').title()}")
|
| 166 |
+
for pair in pairs:
|
| 167 |
+
with st.expander(f"**{pair['question']}**"):
|
| 168 |
+
st.markdown(f"**Candidate's Answer:** *{pair['answer']}*")
|
| 169 |
+
if 'analysis' in pair and 'scores' in pair['analysis']:
|
| 170 |
+
st.markdown("**AI Analysis:**")
|
| 171 |
+
for score_item in pair['analysis']['scores']:
|
| 172 |
+
category_name = score_item.get('category', 'General').replace('_', ' ').title()
|
| 173 |
+
score = score_item.get('score', 'N/A')
|
| 174 |
+
reasoning = score_item.get('reasoning', 'No reasoning provided.')
|
| 175 |
+
st.markdown(f"**{category_name} Score:** `{score}/10`")
|
| 176 |
+
st.info(f"**Explanation:** {reasoning}")
|
| 177 |
+
elif 'analysis' in pair and 'error' in pair['analysis']:
|
| 178 |
+
st.error(f"Could not score this answer: {pair['analysis']['error']}")
|
| 179 |
+
else:
|
| 180 |
+
st.warning("No analysis available for this Q&A pair.")
|
| 181 |
+
|
| 182 |
+
BASE_URL = "http://127.0.0.1:8000"
|
| 183 |
+
RESUME_EXTRACTION_URL = f"{BASE_URL}/extract_from_resume/"
|
| 184 |
+
|
| 185 |
+
def get_text_from_file(file):
|
| 186 |
+
"""Extracts text from an uploaded file (PDF, DOCX, TXT)."""
|
| 187 |
+
text = ""
|
| 188 |
+
try:
|
| 189 |
+
file.seek(0) # Reset file pointer
|
| 190 |
+
if file.type == "application/pdf":
|
| 191 |
+
pdf_reader = pypdf.PdfReader(file)
|
| 192 |
+
raw_text = " ".join(page.extract_text() or "" for page in pdf_reader.pages)
|
| 193 |
+
text = re.sub(r'\s+', ' ', raw_text).strip()
|
| 194 |
+
elif file.type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
| 195 |
+
doc = docx.Document(file)
|
| 196 |
+
raw_text = " ".join(p.text for p in doc.paragraphs)
|
| 197 |
+
text = re.sub(r'\s+', ' ', raw_text).strip()
|
| 198 |
+
elif file.type == "text/plain":
|
| 199 |
+
text = file.read().decode('utf-8')
|
| 200 |
+
else:
|
| 201 |
+
st.warning(f"Unsupported file type: {file.type}")
|
| 202 |
+
return None
|
| 203 |
+
except Exception as e:
|
| 204 |
+
st.error(f"Error reading file: {e}")
|
| 205 |
+
return None
|
| 206 |
+
return text
|
| 207 |
+
|
| 208 |
+
def display_resume_content(file):
|
| 209 |
+
"""Displays the content of the uploaded resume in the UI."""
|
| 210 |
+
resume_text = get_text_from_file(file)
|
| 211 |
+
if resume_text:
|
| 212 |
+
st.text_area("Resume Content", value=resume_text, height=300, disabled=True)
|
| 213 |
+
else:
|
| 214 |
+
st.info("Could not display content for this file type or an error occurred.")
|
| 215 |
+
|
| 216 |
+
def call_analyze_mock_api():
|
| 217 |
+
url = f"{BASE_URL}/analyze_mock/"
|
| 218 |
+
try:
|
| 219 |
+
response = requests.post(url, timeout=120)
|
| 220 |
+
response.raise_for_status()
|
| 221 |
+
return response.json()
|
| 222 |
+
except requests.exceptions.RequestException as e:
|
| 223 |
+
return {"error": f"Failed to connect to backend: {e}"}
|
| 224 |
+
|
| 225 |
+
def call_analyze_interview_api(job_description, rubric_content, audio_file=None, transcript_content=None):
|
| 226 |
+
"""Calls the backend to analyze an interview from audio or transcript."""
|
| 227 |
+
api_url = f"{BASE_URL}/analyze/"
|
| 228 |
+
files = {}
|
| 229 |
+
data = {
|
| 230 |
+
'job_description': job_description,
|
| 231 |
+
'rubric_content': rubric_content
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
if audio_file:
|
| 235 |
+
files['audio_file'] = (audio_file.name, audio_file.getvalue(), audio_file.type)
|
| 236 |
+
elif transcript_content:
|
| 237 |
+
data['transcript_content'] = transcript_content
|
| 238 |
+
else:
|
| 239 |
+
# This case should ideally be prevented by the UI
|
| 240 |
+
return {"error": "No audio file or transcript was provided."}
|
| 241 |
+
|
| 242 |
+
try:
|
| 243 |
+
# Use a long timeout as analysis can take time
|
| 244 |
+
response = requests.post(api_url, files=files, data=data, timeout=300)
|
| 245 |
+
response.raise_for_status()
|
| 246 |
+
return response.json()
|
| 247 |
+
except requests.exceptions.RequestException as e:
|
| 248 |
+
return {"error": f"API request failed: {e}"}
|
| 249 |
+
|
| 250 |
+
def call_analyze_resume_api(resume_file, job_description):
|
| 251 |
+
"""Calls the backend to analyze and score a resume."""
|
| 252 |
+
api_url = f"{BASE_URL}/analyze_resume/"
|
| 253 |
+
files = {'resume_file': (resume_file.name, resume_file.getvalue(), resume_file.type)}
|
| 254 |
+
data = {'job_description': job_description}
|
| 255 |
+
try:
|
| 256 |
+
response = requests.post(api_url, files=files, data=data, timeout=180)
|
| 257 |
+
response.raise_for_status()
|
| 258 |
+
return response.json()
|
| 259 |
+
except requests.exceptions.RequestException as e:
|
| 260 |
+
return {"error": f"API request failed: {e}"}
|
| 261 |
+
|
| 262 |
+
def call_extract_api(resume_file):
|
| 263 |
+
if not resume_file:
|
| 264 |
+
return None
|
| 265 |
+
files = {'resume_file': (resume_file.name, resume_file.getvalue(), resume_file.type)}
|
| 266 |
+
try:
|
| 267 |
+
response = requests.post(RESUME_EXTRACTION_URL, files=files, timeout=60)
|
| 268 |
+
response.raise_for_status()
|
| 269 |
+
return response.json()
|
| 270 |
+
except requests.exceptions.RequestException as e:
|
| 271 |
+
st.error(f"Error connecting to backend for extraction: {e}")
|
| 272 |
+
return {"error": str(e)}
|
| 273 |
+
|
| 274 |
+
# --- PAGE CONFIG & SESSION STATE ---
|
| 275 |
+
|
| 276 |
+
if 'interview_results' not in st.session_state:
|
| 277 |
+
st.session_state.interview_results = None
|
| 278 |
+
if 'use_mock_data' not in st.session_state:
|
| 279 |
+
st.session_state.use_mock_data = False
|
| 280 |
+
if 'candidate_info' not in st.session_state:
|
| 281 |
+
st.session_state.candidate_info = None
|
| 282 |
+
if 'resume_file_name' not in st.session_state:
|
| 283 |
+
st.session_state.resume_file_name = None
|
| 284 |
+
if 'jd_input' not in st.session_state:
|
| 285 |
+
st.session_state.jd_input = None
|
| 286 |
+
|
| 287 |
+
# --- UI LAYOUT ---
|
| 288 |
+
# --- SIDEBAR ---
|
| 289 |
+
st.sidebar.header("⚙️ Configuration")
|
| 290 |
+
st.session_state.use_mock_data = st.sidebar.checkbox("Use Mock Data for Quick Testing", key='use_mock_data_checkbox')
|
| 291 |
+
|
| 292 |
+
if st.session_state.use_mock_data:
|
| 293 |
+
job_description = st.sidebar.text_area("Job Description", value=MOCK_JOB_DESCRIPTION, height=250, disabled=True)
|
| 294 |
+
rubric_content = st.sidebar.text_area("Scoring Rubric (YAML)", value=MOCK_RUBRIC_CONTENT, height=400, disabled=True)
|
| 295 |
+
else:
|
| 296 |
+
job_description = st.sidebar.text_area("Job Description", placeholder="Paste the full job description here...", height=250, key="jd_input")
|
| 297 |
+
rubric_content = st.sidebar.text_area("Scoring Rubric (YAML)", placeholder="Paste the YAML scoring rubric here...", height=400)
|
| 298 |
+
|
| 299 |
+
st.sidebar.markdown("---")
|
| 300 |
+
# st.sidebar.info("Configure the job details here, then manage candidate analysis in the main panel.")
|
| 301 |
+
|
| 302 |
+
# --- MAIN PANEL ---
|
| 303 |
+
# --- Candidate Profile Section ---
|
| 304 |
+
with st.container():
|
| 305 |
+
st.subheader("Candidate Profile")
|
| 306 |
+
profile_col1, profile_col2, profile_col3 = st.columns([1, 1, 2])
|
| 307 |
+
with profile_col1:
|
| 308 |
+
|
| 309 |
+
if st.session_state.use_mock_data:
|
| 310 |
+
uploaded_resume = load_mock_file(MOCK_RESUME_PATH, "application/pdf")
|
| 311 |
+
|
| 312 |
+
else:
|
| 313 |
+
uploaded_resume = st.file_uploader("Upload Candidate Resume/CV", type=["pdf", "docx", "txt"], key="resume_uploader")
|
| 314 |
+
|
| 315 |
+
if uploaded_resume:
|
| 316 |
+
st.image("https://www.w3schools.com/howto/img_avatar.png", width=150)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
if uploaded_resume and uploaded_resume.name != st.session_state.get('resume_file_name'):
|
| 320 |
+
st.session_state.resume_file_name = uploaded_resume.name
|
| 321 |
+
st.session_state.candidate_info = None
|
| 322 |
+
with st.spinner("🤖 Extracting key information from resume..."):
|
| 323 |
+
extracted_data = call_extract_api(uploaded_resume)
|
| 324 |
+
if extracted_data and "error" not in extracted_data:
|
| 325 |
+
st.session_state.candidate_info = extracted_data
|
| 326 |
+
st.success("Information extracted successfully!")
|
| 327 |
+
elif extracted_data:
|
| 328 |
+
st.error(f"Extraction failed: {extracted_data.get('error')}")
|
| 329 |
+
|
| 330 |
+
with profile_col2:
|
| 331 |
+
st.markdown("**Basic Information:**")
|
| 332 |
+
if st.session_state.candidate_info:
|
| 333 |
+
info = st.session_state.candidate_info
|
| 334 |
+
st.write(f"**Full Name:** {info.get('full_name', 'N/A')}")
|
| 335 |
+
st.write(f"**Email:** {info.get('email', 'N/A')}")
|
| 336 |
+
st.write(f"**Phone:** {info.get('phone', 'N/A')}")
|
| 337 |
+
else:
|
| 338 |
+
st.info("Upload a resume to extract details.")
|
| 339 |
+
|
| 340 |
+
with profile_col3:
|
| 341 |
+
st.markdown("**Summaries:**")
|
| 342 |
+
if st.session_state.candidate_info:
|
| 343 |
+
info = st.session_state.candidate_info
|
| 344 |
+
with st.expander("Experience"):
|
| 345 |
+
st.markdown(info.get('experience_summary', 'N/A'))
|
| 346 |
+
with st.expander("Education"):
|
| 347 |
+
st.markdown(info.get('education_summary', 'N/A'))
|
| 348 |
+
with st.expander("Skills"):
|
| 349 |
+
st.markdown(info.get('skill_summary', 'N/A'))
|
| 350 |
+
else:
|
| 351 |
+
st.info("Summaries appear after extraction.")
|
| 352 |
+
|
| 353 |
+
if uploaded_resume:
|
| 354 |
+
st.info(f"Using mock resume: {uploaded_resume.name}",)
|
| 355 |
+
|
| 356 |
+
# --- Analysis Tabs ---
|
| 357 |
+
st.subheader("Assessment")
|
| 358 |
+
resume_tab, interview_tab = st.tabs(["📝 Resume/CV Score", "🎙️ Interview Score"])
|
| 359 |
+
|
| 360 |
+
with interview_tab:
|
| 361 |
+
st.header("Interview Analysis")
|
| 362 |
+
|
| 363 |
+
# Add a radio button for input method selection
|
| 364 |
+
input_method = st.radio("Choose input method:", ("Upload Audio", "Enter Transcript"), key="interview_input_method")
|
| 365 |
+
|
| 366 |
+
audio_file = None
|
| 367 |
+
transcript_input = None
|
| 368 |
+
|
| 369 |
+
if input_method == "Upload Audio":
|
| 370 |
+
if st.session_state.use_mock_data:
|
| 371 |
+
audio_file = load_mock_file(MOCK_AUDIO_PATH, "audio/mp3")
|
| 372 |
+
if audio_file:
|
| 373 |
+
st.info(f"Using mock audio: {audio_file.name}")
|
| 374 |
+
st.audio(audio_file)
|
| 375 |
+
# Keep mock transcript display for context if audio is used
|
| 376 |
+
try:
|
| 377 |
+
with open(MOCK_TRANSCRIPT_PATH, 'r', encoding='utf-8') as f:
|
| 378 |
+
mock_transcript_content = f.read()
|
| 379 |
+
|
| 380 |
+
with st.expander("View Mock Transcript"):
|
| 381 |
+
transcript_input = st.text_area("Paste Transcript Here", value=mock_transcript_content, height=300, disabled=True, placeholder=mock_transcript_content)
|
| 382 |
+
except FileNotFoundError:
|
| 383 |
+
st.warning("Mock transcript file not found.")
|
| 384 |
+
transcript_input = st.text_area("Paste Transcript Here", height=300, placeholder="Mock transcript file not found.", disabled=True)
|
| 385 |
+
else:
|
| 386 |
+
audio_file = st.file_uploader("Upload Interview Audio", type=['mp3', 'wav', 'm4a', 'mp4'], key="live_audio_uploader")
|
| 387 |
+
if audio_file:
|
| 388 |
+
st.audio(audio_file)
|
| 389 |
+
else: # input_method == "Enter Transcript"
|
| 390 |
+
if st.session_state.use_mock_data:
|
| 391 |
+
try:
|
| 392 |
+
with open(MOCK_TRANSCRIPT_PATH, 'r', encoding='utf-8') as f:
|
| 393 |
+
transcript_input = f.read()
|
| 394 |
+
st.info("Using mock transcript.")
|
| 395 |
+
transcript_input = st.text_area("Paste Transcript Here", value=transcript_input, height=300, disabled=True)
|
| 396 |
+
except FileNotFoundError:
|
| 397 |
+
st.warning("Mock transcript file not found.")
|
| 398 |
+
transcript_input = st.text_area("Paste Transcript Here", height=300, placeholder="Mock transcript file not found.", disabled=True)
|
| 399 |
+
else:
|
| 400 |
+
with open(MOCK_TRANSCRIPT_PATH, 'r', encoding='utf-8') as f:
|
| 401 |
+
transcript_input = f.read()
|
| 402 |
+
# st.info("Using mock transcript.")
|
| 403 |
+
transcript_input = st.text_area("Paste Transcript Here", height=300, placeholder=transcript_input)
|
| 404 |
+
|
| 405 |
+
# Initialize response_data outside the button block
|
| 406 |
+
response_data = None
|
| 407 |
+
|
| 408 |
+
# Determine if the analyze button should be enabled
|
| 409 |
+
# This needs to be done after transcript_input and audio_file are potentially set
|
| 410 |
+
analyze_button_disabled = (not audio_file and not transcript_input)
|
| 411 |
+
|
| 412 |
+
if st.button("Analyze Interview", key="analyze_interview_btn", disabled=analyze_button_disabled):
|
| 413 |
+
if st.session_state.use_mock_data:
|
| 414 |
+
with st.spinner('Analyzing mock interview...'):
|
| 415 |
+
response_data = call_analyze_mock_api()
|
| 416 |
+
elif audio_file:
|
| 417 |
+
with st.spinner('Analyzing interview... This may take several minutes.'):
|
| 418 |
+
response_data = call_analyze_interview_api(job_description, rubric_content, audio_file=audio_file)
|
| 419 |
+
elif transcript_input:
|
| 420 |
+
with st.spinner('Analyzing interview... This may take several minutes.'):
|
| 421 |
+
response_data = call_analyze_interview_api(job_description, rubric_content, transcript_content=transcript_input)
|
| 422 |
+
|
| 423 |
+
if response_data and "error" not in response_data:
|
| 424 |
+
st.session_state.interview_results = response_data
|
| 425 |
+
st.success('Interview analysis complete!')
|
| 426 |
+
elif response_data:
|
| 427 |
+
st.error(f"API Error: {response_data.get('error')}")
|
| 428 |
+
if 'interview_results' in st.session_state:
|
| 429 |
+
del st.session_state.interview_results
|
| 430 |
+
|
| 431 |
+
if st.session_state.get('interview_results'):
|
| 432 |
+
display_results(st.session_state.interview_results, rubric_content)
|
| 433 |
+
|
| 434 |
+
with resume_tab:
|
| 435 |
+
st.header("Resume Analysis")
|
| 436 |
+
|
| 437 |
+
if uploaded_resume:
|
| 438 |
+
with st.expander("View Uploaded Resume Content"):
|
| 439 |
+
display_resume_content(uploaded_resume)
|
| 440 |
+
|
| 441 |
+
# Button to trigger analysis
|
| 442 |
+
if st.button("Analyze Resume Score", key="analyze_resume_btn", type="primary", disabled=(not uploaded_resume or not job_description)):
|
| 443 |
+
with st.spinner('Analyzing resume... This may take a moment.'):
|
| 444 |
+
# Call the backend API
|
| 445 |
+
api_results = call_analyze_resume_api(uploaded_resume, job_description)
|
| 446 |
+
|
| 447 |
+
# Store results or handle errors
|
| 448 |
+
if api_results and 'error' not in api_results:
|
| 449 |
+
st.session_state.resume_score = api_results
|
| 450 |
+
st.success('Resume analysis complete!')
|
| 451 |
+
else:
|
| 452 |
+
error_message = api_results.get('error', 'An unknown error occurred.') if api_results else 'An unknown error occurred.'
|
| 453 |
+
st.error(f"Analysis failed: {error_message}")
|
| 454 |
+
if 'resume_score' in st.session_state:
|
| 455 |
+
del st.session_state.resume_score # Clear old results on failure
|
| 456 |
+
|
| 457 |
+
# Display results if they exist in the session state
|
| 458 |
+
if 'resume_score' in st.session_state and st.session_state.resume_score:
|
| 459 |
+
results = st.session_state.resume_score.get('results', {})
|
| 460 |
+
|
| 461 |
+
# Safely get nested data
|
| 462 |
+
summary = results.get('overall_summary', 'No summary provided.')
|
| 463 |
+
overall_score = calculate_resume_overall_score(results)
|
| 464 |
+
exp = results.get('experience', {})
|
| 465 |
+
edu = results.get('education', {})
|
| 466 |
+
skills = results.get('skills', {})
|
| 467 |
+
|
| 468 |
+
st.markdown(f"### Overall Score: {overall_score}/100")
|
| 469 |
+
st.markdown("---")
|
| 470 |
+
|
| 471 |
+
st.subheader("Score Breakdown")
|
| 472 |
+
score_col1, score_col2, score_col3 = st.columns(3)
|
| 473 |
+
score_col1.metric(label="Experience Score", value=f"{exp.get('score', 'N/A')}")
|
| 474 |
+
score_col2.metric(label="Education Score", value=f"{edu.get('score', 'N/A')}")
|
| 475 |
+
score_col3.metric(label="Skills Match", value=f"{skills.get('score', 'N/A')}")
|
| 476 |
+
|
| 477 |
+
st.subheader("Overall Summary")
|
| 478 |
+
st.write(summary)
|
| 479 |
+
|
| 480 |
+
st.subheader("Detailed Analysis")
|
| 481 |
+
with st.expander("**Experience Analysis**"):
|
| 482 |
+
st.write(exp.get('justification', 'No justification provided.'))
|
| 483 |
+
with st.expander("**Education Analysis**"):
|
| 484 |
+
st.write(edu.get('justification', 'No justification provided.'))
|
| 485 |
+
with st.expander("**Skills Match Analysis**"):
|
| 486 |
+
st.write(skills.get('justification', 'No justification provided.'))
|
| 487 |
+
else:
|
| 488 |
+
# Default view shown on page load or when no analysis has been run
|
| 489 |
+
# st.info("Upload a CV and provide a Job Description in the sidebar to start the analysis.")
|
| 490 |
+
st.markdown("---")
|
| 491 |
+
# st.subheader("Score Summary")
|
| 492 |
+
# score_col1, score_col2, score_col3 = st.columns(3)
|
| 493 |
+
# score_col1.metric(label="Experience Score", value="N/A")
|
| 494 |
+
# score_col2.metric(label="Education Score", value="N/A")
|
| 495 |
+
# score_col3.metric(label="Skills Match", value="N/A")
|
assets/sample_rubric.yaml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example Rubric: Define criteria and keywords
|
| 2 |
+
# The AI will score the candidate's answers based on these categories.
|
| 3 |
+
|
| 4 |
+
technical_skills:
|
| 5 |
+
description: "Proficiency in core technologies and concepts."
|
| 6 |
+
weight: 0.6
|
| 7 |
+
criteria:
|
| 8 |
+
- "Python proficiency: including standard libraries and data structures."
|
| 9 |
+
- "Experience with FastAPI: building APIs, routing, data models."
|
| 10 |
+
- "Understanding of RESTful principles."
|
| 11 |
+
|
| 12 |
+
communication_skills:
|
| 13 |
+
description: "Clarity, conciseness, and ability to explain complex topics."
|
| 14 |
+
weight: 0.4
|
| 15 |
+
criteria:
|
| 16 |
+
- "Clearly articulates thoughts and ideas."
|
| 17 |
+
- "Provides structured and easy-to-follow answers."
|
| 18 |
+
- "Confidently explains past projects and experiences."
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
requests
|
| 3 |
+
pypdf
|
| 4 |
+
python-docx
|