Spaces:
Build error
Build error
initial commit
Browse files- .DS_Store +0 -0
- app.py +49 -0
- requirements.txt +4 -0
- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-311.pyc +0 -0
- src/__pycache__/graph.cpython-311.pyc +0 -0
- src/__pycache__/interview_logic.cpython-311.pyc +0 -0
- src/__pycache__/perplexity_detector.cpython-311.pyc +0 -0
- src/graph.py +45 -0
- src/interview_logic.py +84 -0
- src/local_llm_handler.py +55 -0
- src/perplexity_detector.py +31 -0
.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from src.graph import build_graph
|
| 3 |
+
|
| 4 |
+
def main():
|
| 5 |
+
# Build LangGraph workflow
|
| 6 |
+
app = build_graph()
|
| 7 |
+
|
| 8 |
+
def run_graph(current_state: dict) -> dict:
|
| 9 |
+
return app.invoke(current_state)
|
| 10 |
+
|
| 11 |
+
st.title("🤖 AI-Powered Excel Mock Interviewer (Phi-3-mini, Local LLM)")
|
| 12 |
+
st.markdown("Welcome! This tool uses a self-hosted open-source model to conduct the interview. Click 'Start New Interview' to begin.")
|
| 13 |
+
|
| 14 |
+
# Session State Initialization
|
| 15 |
+
if "interviewer_state" not in st.session_state:
|
| 16 |
+
st.session_state.interviewer_state = {
|
| 17 |
+
"interview_status": 0, "interview_history": [], "questions": [],
|
| 18 |
+
"question_index": 0, "evaluations": [], "final_feedback": "", "warnings": []
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# Display chat history
|
| 22 |
+
for message in st.session_state.interviewer_state.get("interview_history", []):
|
| 23 |
+
role, text = message
|
| 24 |
+
with st.chat_message("user" if role == "human" else "assistant"):
|
| 25 |
+
st.markdown(text)
|
| 26 |
+
|
| 27 |
+
# Button handling
|
| 28 |
+
if st.button("Start New Interview"):
|
| 29 |
+
st.session_state.interviewer_state = {
|
| 30 |
+
"interview_status": 0, "interview_history": [], "questions": [],
|
| 31 |
+
"question_index": 0, "evaluations": [], "final_feedback": "", "warnings": []
|
| 32 |
+
}
|
| 33 |
+
new_state = run_graph(st.session_state.interviewer_state)
|
| 34 |
+
st.session_state.interviewer_state = new_state
|
| 35 |
+
st.rerun()
|
| 36 |
+
|
| 37 |
+
# Input handling
|
| 38 |
+
if st.session_state.interviewer_state.get("interview_status") == 1:
|
| 39 |
+
prompt = st.chat_input("Your answer...")
|
| 40 |
+
if prompt:
|
| 41 |
+
st.session_state.interviewer_state["interview_history"].append(("human", prompt))
|
| 42 |
+
new_state = run_graph(st.session_state.interviewer_state)
|
| 43 |
+
st.session_state.interviewer_state = new_state
|
| 44 |
+
st.rerun()
|
| 45 |
+
elif st.session_state.interviewer_state.get("interview_status") == 2:
|
| 46 |
+
st.success("Interview complete!")
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers>=4.41.0
|
| 2 |
+
torch
|
| 3 |
+
streamlit
|
| 4 |
+
langgraph
|
src/__init__.py
ADDED
|
File without changes
|
src/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (205 Bytes). View file
|
|
|
src/__pycache__/graph.cpython-311.pyc
ADDED
|
Binary file (1.74 kB). View file
|
|
|
src/__pycache__/interview_logic.cpython-311.pyc
ADDED
|
Binary file (8.19 kB). View file
|
|
|
src/__pycache__/perplexity_detector.cpython-311.pyc
ADDED
|
Binary file (2.91 kB). View file
|
|
|
src/graph.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langgraph.graph import StateGraph, END
|
| 2 |
+
from . import interview_logic
|
| 3 |
+
|
| 4 |
+
def build_graph():
|
| 5 |
+
"""
|
| 6 |
+
Builds the LangGraph for the mock interview process.
|
| 7 |
+
"""
|
| 8 |
+
workflow = StateGraph(dict)
|
| 9 |
+
|
| 10 |
+
# Add nodes (no changes here)
|
| 11 |
+
workflow.add_node("start_interview", interview_logic.start_interview)
|
| 12 |
+
workflow.add_node("ask_question", interview_logic.ask_question)
|
| 13 |
+
workflow.add_node("process_user_response", interview_logic.process_user_response)
|
| 14 |
+
workflow.add_node("generate_final_report", interview_logic.generate_final_report)
|
| 15 |
+
|
| 16 |
+
# Entry point (no changes here)
|
| 17 |
+
workflow.set_conditional_entry_point(
|
| 18 |
+
interview_logic.route_start_of_interview,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# --- THIS IS THE CRITICAL FIX ---
|
| 22 |
+
|
| 23 |
+
# The flow from start to the first question is correct.
|
| 24 |
+
workflow.add_edge("start_interview", "ask_question")
|
| 25 |
+
|
| 26 |
+
# After asking a question, the graph should STOP for that turn.
|
| 27 |
+
# This allows the UI to wait for user input.
|
| 28 |
+
workflow.add_edge("ask_question", END) # <--- THIS IS THE FIX
|
| 29 |
+
|
| 30 |
+
# The conditional edge should ONLY come from process_user_response
|
| 31 |
+
workflow.add_conditional_edges(
|
| 32 |
+
"process_user_response",
|
| 33 |
+
interview_logic.route_after_evaluation,
|
| 34 |
+
{
|
| 35 |
+
"ask_question": "ask_question",
|
| 36 |
+
"generate_final_report": "generate_final_report",
|
| 37 |
+
"terminate": END
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# The final report node still ends the graph normally.
|
| 42 |
+
workflow.add_edge("generate_final_report", END)
|
| 43 |
+
|
| 44 |
+
# Compile the graph
|
| 45 |
+
return workflow.compile()
|
src/interview_logic.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/interview_logic.py
|
| 2 |
+
|
| 3 |
+
# No more external LLM libraries needed here!
|
| 4 |
+
from .perplexity_detector import is_ai_generated
|
| 5 |
+
from .local_llm_handler import get_llm_response
|
| 6 |
+
|
| 7 |
+
EXCEL_QUESTIONS = [
|
| 8 |
+
"What is the difference between the VLOOKUP and HLOOKUP functions in Excel?",
|
| 9 |
+
"Explain how to use the INDEX and MATCH functions together, and why you might prefer them over VLOOKUP.",
|
| 10 |
+
"Describe what a Pivot Table is and give an example of a scenario where it would be useful.",
|
| 11 |
+
"What is Conditional Formatting in Excel? Can you provide an example?",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
def start_interview(state: dict) -> dict:
|
| 15 |
+
# This function body remains largely the same
|
| 16 |
+
intro_message = "Welcome to the automated Excel skills assessment..."
|
| 17 |
+
return {
|
| 18 |
+
**state, "interview_status": 1, "interview_history": [("ai", intro_message)],
|
| 19 |
+
"questions": EXCEL_QUESTIONS, "question_index": 0, "evaluations": [], "warnings": [], "final_feedback": "",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
def ask_question(state: dict) -> dict:
|
| 23 |
+
# This function remains the same
|
| 24 |
+
question_index = state["question_index"]
|
| 25 |
+
current_question = state["questions"][question_index]
|
| 26 |
+
history = state.get("interview_history", [])
|
| 27 |
+
history.append(("ai", current_question))
|
| 28 |
+
return { **state, "interview_history": history }
|
| 29 |
+
|
| 30 |
+
# --- MODIFIED FUNCTION ---
|
| 31 |
+
def process_user_response(state: dict) -> dict:
|
| 32 |
+
history = state.get("interview_history", [])
|
| 33 |
+
user_response = history[-1][1]
|
| 34 |
+
|
| 35 |
+
if is_ai_generated(user_response, threshold=35.0):
|
| 36 |
+
termination_message = "This interview will now be terminated..."
|
| 37 |
+
history.append(("ai", termination_message))
|
| 38 |
+
return {**state, "interview_history": history, "interview_status": 2}
|
| 39 |
+
|
| 40 |
+
current_question = state["questions"][state["question_index"]]
|
| 41 |
+
|
| 42 |
+
# Create the prompt manually for our local LLM
|
| 43 |
+
evaluation_prompt = (
|
| 44 |
+
"You are an expert evaluator. Concisely evaluate the following answer to an Excel interview question "
|
| 45 |
+
"based on its technical accuracy and clarity. Start the evaluation directly without conversational filler.\n\n"
|
| 46 |
+
f"Question: {current_question}\n"
|
| 47 |
+
f"Answer: {user_response}"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Get the evaluation from our new local LLM handler
|
| 51 |
+
evaluation = get_llm_response(evaluation_prompt)
|
| 52 |
+
|
| 53 |
+
evaluations = state.get("evaluations", [])
|
| 54 |
+
evaluations.append(evaluation)
|
| 55 |
+
|
| 56 |
+
return {**state, "evaluations": evaluations, "question_index": state["question_index"] + 1}
|
| 57 |
+
|
| 58 |
+
def generate_final_report(state: dict) -> dict:
|
| 59 |
+
interview_transcript = "\n".join([f"{speaker.capitalize()}: {text}" for speaker, text in state['interview_history']])
|
| 60 |
+
evaluations_summary = "\n\n".join(f"Evaluation for Q{i+1}:\n{e}" for i, e in enumerate(state['evaluations']))
|
| 61 |
+
|
| 62 |
+
# Create the report prompt manually
|
| 63 |
+
report_prompt = (
|
| 64 |
+
"You are a career coach. Based on the interview transcript and evaluations below, write a brief, constructive performance summary. "
|
| 65 |
+
"Use Markdown for a 'Strengths' section and an 'Areas for Improvement' section.\n\n"
|
| 66 |
+
f"---TRANSCRIPT---\n{interview_transcript}\n\n---EVALUATIONS---\n{evaluations_summary}"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Get the report from our local LLM handler
|
| 70 |
+
final_feedback = get_llm_response(report_prompt)
|
| 71 |
+
|
| 72 |
+
history = state.get("interview_history", [])
|
| 73 |
+
history.append(("ai", final_feedback))
|
| 74 |
+
|
| 75 |
+
return {**state, "final_feedback": final_feedback, "interview_history": history, "interview_status": 2}
|
| 76 |
+
|
| 77 |
+
# Routing logic does not need to change
|
| 78 |
+
def route_after_evaluation(state: dict):
|
| 79 |
+
if state.get("interview_status") == 2: return "terminate"
|
| 80 |
+
elif state["question_index"] >= len(state["questions"]): return "generate_final_report"
|
| 81 |
+
else: return "ask_question"
|
| 82 |
+
|
| 83 |
+
def route_start_of_interview(state: dict):
|
| 84 |
+
return "start_interview" if state.get("interview_status", 0) == 0 else "process_user_response"
|
src/local_llm_handler.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/local_llm_handler.py
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
| 9 |
+
|
| 10 |
+
@st.cache_resource
|
| 11 |
+
def load_llm_pipeline():
|
| 12 |
+
"""
|
| 13 |
+
Loads and caches the local LLM pipeline using Phi-3-mini-4k-instruct.
|
| 14 |
+
Designed for Hugging Face Spaces (with upgraded CPU or T4 GPU).
|
| 15 |
+
"""
|
| 16 |
+
print("--- Loading main LLM: microsoft/Phi-3-mini-4k-instruct ---")
|
| 17 |
+
model_name = "microsoft/phi-3-mini-4k-instruct"
|
| 18 |
+
|
| 19 |
+
# Load tokenizer and model
|
| 20 |
+
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
|
| 21 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 22 |
+
model_name,
|
| 23 |
+
device_map="auto", # Automatically uses GPU if available
|
| 24 |
+
torch_dtype=torch.float32, # Use float16 for memory efficiency
|
| 25 |
+
trust_remote_code=True
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Build text generation pipeline
|
| 29 |
+
llm_pipeline = pipeline(
|
| 30 |
+
"text-generation",
|
| 31 |
+
model=model,
|
| 32 |
+
tokenizer=tokenizer,
|
| 33 |
+
max_new_tokens=300,
|
| 34 |
+
return_full_text=False
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
print("--- Phi-3-mini model loaded successfully ---")
|
| 38 |
+
return llm_pipeline
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_llm_response(prompt: str) -> str:
|
| 42 |
+
"""
|
| 43 |
+
Gets a response from the cached Phi-3-mini LLM pipeline.
|
| 44 |
+
"""
|
| 45 |
+
llm_pipeline = load_llm_pipeline()
|
| 46 |
+
formatted_prompt = f"<|user|>\n{prompt}\n<|assistant|>"
|
| 47 |
+
|
| 48 |
+
print("AI: (Generating response with Phi-3-mini...)")
|
| 49 |
+
try:
|
| 50 |
+
outputs = llm_pipeline(formatted_prompt)
|
| 51 |
+
response = outputs[0]["generated_text"]
|
| 52 |
+
return response
|
| 53 |
+
except Exception as e:
|
| 54 |
+
print(f"Error during Phi-3-mini generation: {e}")
|
| 55 |
+
return "Sorry, I encountered an error while generating a response."
|
src/perplexity_detector.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/perplexity_detector.py
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
@st.cache_resource
|
| 8 |
+
def load_detector_model():
|
| 9 |
+
"""Loads and caches the gpt-2 model for perplexity calculation."""
|
| 10 |
+
print("--- Loading detector model (gpt-2) for the first time... ---")
|
| 11 |
+
model_name = "gpt2"
|
| 12 |
+
model = AutoModelForCausalLM.from_pretrained(model_name)
|
| 13 |
+
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 14 |
+
print("--- Detector model loaded and cached. ---")
|
| 15 |
+
return model, tokenizer
|
| 16 |
+
|
| 17 |
+
def calculate_perplexity(text: str) -> float:
|
| 18 |
+
model, tokenizer = load_detector_model()
|
| 19 |
+
encodings = tokenizer(text, return_tensors="pt")
|
| 20 |
+
with torch.no_grad():
|
| 21 |
+
outputs = model(**encodings, labels=encodings["input_ids"])
|
| 22 |
+
neg_log_likelihood = outputs.loss
|
| 23 |
+
ppl = torch.exp(neg_log_likelihood)
|
| 24 |
+
return ppl.item()
|
| 25 |
+
|
| 26 |
+
def is_ai_generated(text: str, threshold: float = 45.0) -> bool:
|
| 27 |
+
if not text or len(text.split()) < 5: return False
|
| 28 |
+
print("AI: (Calculating perplexity...)")
|
| 29 |
+
perplexity = calculate_perplexity(text)
|
| 30 |
+
print(f"AI: (Perplexity score: {perplexity:.2f}, Threshold: {threshold})")
|
| 31 |
+
return perplexity < threshold
|