Initial commit: AI Interview System

This commit is contained in:
111
2026-01-23 13:57:48 +08:00
commit 95770afe21
127 changed files with 24686 additions and 0 deletions

View File

@@ -0,0 +1 @@
# Routers

View File

@@ -0,0 +1,239 @@
"""
后台管理 API
"""
from fastapi import APIRouter, HTTPException, Depends
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel
from typing import Optional, List, Any
from loguru import logger
import httpx
import json
import secrets
from app.config import settings
router = APIRouter(prefix="/api/admin", tags=["admin"])
security = HTTPBasic()
# 管理员凭证
ADMIN_USERNAME = "admin"
ADMIN_PASSWORD = "admin"
# Coze 配置
COZE_PAT_TOKEN = settings.COZE_PAT_TOKEN
WORKFLOW_QUERY_ID = "7597376294612107318"
def verify_admin(credentials: HTTPBasicCredentials = Depends(security)):
"""验证管理员凭证"""
is_username_correct = secrets.compare_digest(credentials.username, ADMIN_USERNAME)
is_password_correct = secrets.compare_digest(credentials.password, ADMIN_PASSWORD)
if not (is_username_correct and is_password_correct):
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
class LoginRequest(BaseModel):
"""登录请求"""
username: str
password: str
class ApiResponse(BaseModel):
"""API 响应"""
code: int = 0
message: str = "success"
data: Any = None
@router.post("/login", response_model=ApiResponse)
async def login(request: LoginRequest):
"""
管理员登录
"""
if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD:
return ApiResponse(data={"token": "admin_token", "username": ADMIN_USERNAME})
raise HTTPException(status_code=401, detail="用户名或密码错误")
async def execute_workflow(table: str, sql: str) -> dict:
"""
执行 Coze 工作流(通用 SQL 查询)
Args:
table: 表名 - assessments / logs / config
sql: SQL 语句
"""
url = "https://api.coze.cn/v1/workflow/run"
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json"
}
# 构建 JSON 格式的 input
input_data = json.dumps({
"table": table,
"sql": sql
}, ensure_ascii=False)
payload = {
"workflow_id": WORKFLOW_QUERY_ID,
"parameters": {
"input": input_data
}
}
logger.info(f"Execute workflow: table={table}, sql={sql[:80]}...")
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
data = response.json()
logger.info(f"Workflow response code: {data.get('code')}")
if data.get("code") == 0:
# 解析返回数据
result_str = data.get("data", "")
if result_str:
try:
parsed = json.loads(result_str)
# 工作流返回格式: {"output": [...]}
if isinstance(parsed, dict) and "output" in parsed:
return parsed["output"]
return parsed
except:
return {"raw": result_str}
return []
else:
error_msg = data.get("msg", "Unknown error")
logger.error(f"Workflow error: {error_msg}")
raise HTTPException(status_code=500, detail=error_msg)
# ==================== 面试评估 ====================
@router.get("/interviews", response_model=ApiResponse)
async def get_interviews(
page: int = 1,
page_size: int = 20,
session_id: Optional[str] = None
):
"""
获取面试列表
"""
offset = (page - 1) * page_size
if session_id:
sql = f"""
SELECT session_id, candidate_name, bstudio_create_time,
sales_skill_score, sales_concept_score, competency_score,
final_score_report, current_stage
FROM ci_interview_assessments
WHERE session_id = '{session_id}'
"""
else:
sql = f"""
SELECT session_id, candidate_name, bstudio_create_time,
sales_skill_score, sales_concept_score, competency_score,
final_score_report, current_stage
FROM ci_interview_assessments
ORDER BY bstudio_create_time DESC
LIMIT {page_size} OFFSET {offset}
"""
result = await execute_workflow("assessments", sql)
return ApiResponse(data=result)
@router.get("/interviews/{session_id}", response_model=ApiResponse)
async def get_interview_detail(session_id: str):
"""
获取面试详情(完整评估报告)
"""
sql = f"SELECT * FROM ci_interview_assessments WHERE session_id = '{session_id}'"
result = await execute_workflow("assessments", sql)
# 返回第一条记录
if isinstance(result, list) and len(result) > 0:
return ApiResponse(data=result[0])
return ApiResponse(data=result)
@router.get("/interviews/{session_id}/logs", response_model=ApiResponse)
async def get_interview_logs(session_id: str):
"""
获取面试对话记录
"""
sql = f"""
SELECT log_id, session_id, stage, round, ai_question, user_answer, log_type, bstudio_create_time
FROM ci_interview_logs
WHERE session_id = '{session_id}'
ORDER BY bstudio_create_time ASC
"""
result = await execute_workflow("logs", sql)
return ApiResponse(data=result)
@router.delete("/interviews/{session_id}", response_model=ApiResponse)
async def delete_interview(session_id: str):
"""
删除面试记录(同时删除评估和日志)
"""
# 删除评估
sql1 = f"DELETE FROM ci_interview_assessments WHERE session_id = '{session_id}'"
await execute_workflow("assessments", sql1)
# 删除日志
sql2 = f"DELETE FROM ci_interview_logs WHERE session_id = '{session_id}'"
await execute_workflow("logs", sql2)
return ApiResponse(message="删除成功")
# ==================== 业务配置 ====================
@router.get("/configs", response_model=ApiResponse)
async def get_configs(config_type: Optional[str] = None):
"""
获取业务配置列表
"""
if config_type:
sql = f"""
SELECT config_id, config_type, item_name, content, bstudio_create_time
FROM ci_business_config
WHERE config_type = '{config_type}'
ORDER BY bstudio_create_time DESC
"""
else:
sql = """
SELECT config_id, config_type, item_name, content, bstudio_create_time
FROM ci_business_config
ORDER BY config_type, bstudio_create_time DESC
"""
result = await execute_workflow("config", sql)
return ApiResponse(data=result)
# ==================== 统计 ====================
@router.get("/stats", response_model=ApiResponse)
async def get_stats():
"""
获取统计数据
"""
# 总面试数
sql_total = "SELECT COUNT(*) as total FROM ci_interview_assessments"
total_result = await execute_workflow("assessments", sql_total)
return ApiResponse(data={
"total_interviews": total_result
})

View File

@@ -0,0 +1,83 @@
"""
候选人相关接口
"""
import time
import uuid
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from loguru import logger
from app.schemas import ApiResponse, SubmitCandidateResponse
from app.services.coze_service import coze_service
router = APIRouter()
def generate_session_id(name: str) -> str:
"""生成会话 ID"""
timestamp = int(time.time())
random_code = uuid.uuid4().hex[:6]
return f"SESS_{timestamp}_{name}_{random_code}"
@router.post("/candidates", response_model=ApiResponse)
async def submit_candidate(
name: str = Form(..., min_length=2, max_length=20, description="候选人姓名"),
resume: UploadFile = File(..., description="简历文件"),
):
"""
提交候选人信息(上传简历)
- 上传简历到 Coze
- 生成 session_id
- 返回 session_id 和 file_id
"""
try:
# 验证文件类型
allowed_types = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]
if resume.content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail="只支持 PDF、DOC、DOCX 格式的文件"
)
# 验证文件大小10MB
content = await resume.read()
if len(content) > 10 * 1024 * 1024:
raise HTTPException(
status_code=400,
detail="文件大小不能超过 10MB"
)
# 上传文件到 Coze
file_result = await coze_service.upload_file(content, resume.filename or "resume.pdf")
file_id = file_result.get("id")
if not file_id:
raise HTTPException(
status_code=500,
detail="文件上传失败"
)
# 生成 session_id
session_id = generate_session_id(name)
logger.info(f"Candidate submitted: name={name}, session_id={session_id}, file_id={file_id}")
return ApiResponse(
code=0,
message="success",
data=SubmitCandidateResponse(
sessionId=session_id,
fileId=file_id,
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Submit candidate error: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,48 @@
"""
文本对话接口(文字面试模式)
"""
from fastapi import APIRouter, HTTPException
from loguru import logger
from app.schemas import ApiResponse, ChatRequest, ChatResponse
from app.services.coze_service import coze_service
router = APIRouter()
@router.post("/chat", response_model=ApiResponse)
async def chat(request: ChatRequest):
"""
文本对话(文字面试模式)
使用 /v3/chat API 与 Chatflow 对话。
session_id 通过 user_id 传递,工作流从 USER_INPUT 获取。
"""
try:
logger.info(f"Chat request: session_id={request.sessionId}, conv_id={request.conversationId}, message={request.message[:50]}...")
# 使用 /v3/chat APIChatflow 对话)
# session_id 作为 user_id 传递,工作流从 {{USER_INPUT}} 获取
result = await coze_service.chat(
message=request.message,
user_id=request.sessionId, # session_id 作为 user_id
conversation_id=request.conversationId,
)
response_data = ChatResponse(
reply=result.get("reply", ""),
conversationId=result.get("conversation_id", ""),
debugInfo=result.get("debug_info"),
)
logger.info(f"Chat response: {response_data.reply[:50] if response_data.reply else 'empty'}...")
return ApiResponse(
code=0,
message="success",
data=response_data
)
except Exception as e:
logger.error(f"Chat error: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,35 @@
"""
文件服务接口 - 提供文件下载
"""
import os
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from loguru import logger
from app.config import settings
router = APIRouter()
@router.get("/files/{file_id}")
async def download_file(file_id: str):
"""
下载文件(供 Coze 工作流访问)
文件路径: uploads/{file_id}.pdf
"""
# 构建文件路径
file_path = os.path.join(settings.UPLOAD_DIR, f"{file_id}.pdf")
# 检查文件是否存在
if not os.path.exists(file_path):
logger.error(f"File not found: {file_path}")
raise HTTPException(status_code=404, detail="文件不存在")
logger.info(f"Serving file: {file_path}")
return FileResponse(
path=file_path,
filename=f"{file_id}.pdf",
media_type="application/pdf"
)

View File

@@ -0,0 +1,69 @@
"""
面试初始化接口
"""
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from loguru import logger
from app.schemas import ApiResponse
from app.services.coze_service import coze_service
router = APIRouter()
@router.post("/init-interview", response_model=ApiResponse)
async def init_interview(
name: str = Form(..., description="候选人姓名"),
file: UploadFile = File(..., description="简历文件PDF"),
):
"""
初始化面试
1. 上传简历到 Coze
2. 获取文件临时 URL
3. 调用工作流 A解析简历、创建记录
4. 返回 session_id
后续使用 session_id 创建语音房间进行面试
"""
try:
# 验证文件类型
if not file.filename.lower().endswith('.pdf'):
raise HTTPException(status_code=400, detail="仅支持 PDF 格式")
# 读取文件内容
content = await file.read()
# 验证文件大小(最大 10MB
if len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
logger.info(f"Init interview: name={name}, file={file.filename}, size={len(content)} bytes")
# 执行完整的初始化流程(返回详细调试信息)
result = await coze_service.init_interview(
name=name,
file_content=content,
filename=file.filename,
)
session_id = result.get("session_id", "")
debug_info = result.get("debug_info", {})
logger.info(f"Interview initialized: session_id={session_id}")
logger.info(f"Debug info: {debug_info}")
return ApiResponse(
code=0,
message="success",
data={
"sessionId": session_id,
"name": name,
"debugInfo": debug_info,
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Init interview error: {e}")
raise HTTPException(status_code=500, detail=str(e))

129
backend/app/routers/room.py Normal file
View File

@@ -0,0 +1,129 @@
"""
房间相关接口
"""
import time
import uuid
from fastapi import APIRouter, HTTPException
from loguru import logger
from app.schemas import ApiResponse, CreateRoomRequest, CreateRoomResponse
from app.services.coze_service import coze_service
from app.config import settings
router = APIRouter()
def generate_user_id() -> str:
"""生成用户 ID"""
return f"user_{uuid.uuid4().hex[:12]}"
def generate_session_id() -> str:
"""生成会话 ID"""
timestamp = int(time.time())
random_code = uuid.uuid4().hex[:6]
return f"SESS_{timestamp}_{random_code}"
@router.post("/rooms", response_model=ApiResponse)
async def create_room(request: CreateRoomRequest):
"""
创建语音房间
方案A前端不预先收集姓名和简历
- sessionId 和 fileId 都是可选的
- 如果没有传入 sessionId后端自动生成
- 姓名和简历由 Coze 工作流在对话中收集
"""
try:
# 生成用户 ID
user_id = generate_user_id()
# 如果没有传入 sessionId自动生成
session_id = request.sessionId or generate_session_id()
# 调用 Coze API 创建语音房间
# Coze 会自动让 Bot 加入房间,并返回 RTC 连接信息
room_data = await coze_service.create_audio_room(
user_id=user_id,
file_id=request.fileId, # 可能是 None
session_id=session_id,
)
# Coze 返回的字段映射
# Coze: app_id, room_id, token, uid
# 前端期望: appId, roomId, token, userId, sessionId
response_data = CreateRoomResponse(
appId=room_data.get("app_id", ""),
roomId=room_data.get("room_id", ""),
token=room_data.get("token", ""),
userId=room_data.get("uid", user_id),
sessionId=session_id, # 返回 sessionId 给前端
debugInfo=room_data.get("debug_info"), # 调试信息
)
logger.info(f"Room created: session_id={session_id}, room_id={response_data.roomId}")
return ApiResponse(
code=0,
message="success",
data=response_data
)
except Exception as e:
logger.error(f"Create room error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/interviews/{session_id}/end", response_model=ApiResponse)
async def end_interview(session_id: str):
"""
结束面试
- 通知后端面试已结束
- 可以在这里添加后续处理逻辑
"""
try:
logger.info(f"Interview ended: session_id={session_id}")
# TODO: 可以在这里添加后续处理逻辑
# 例如:更新状态、发送通知等
return ApiResponse(
code=0,
message="success",
data={"success": True}
)
except Exception as e:
logger.error(f"End interview error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/coze-config", response_model=ApiResponse)
async def get_coze_config():
"""
获取 Coze Realtime SDK 配置
返回前端需要的配置信息,用于直接连接 Coze Realtime
注意:这种方式会暴露 PAT Token 到浏览器,仅用于开发/测试
"""
try:
config = {
"accessToken": settings.COZE_PAT_TOKEN,
"botId": settings.COZE_BOT_ID,
"voiceId": settings.COZE_VOICE_ID,
"connectorId": "1024", # 固定值
}
logger.info(f"Coze config requested: bot_id={settings.COZE_BOT_ID}")
return ApiResponse(
code=0,
message="success",
data=config
)
except Exception as e:
logger.error(f"Get coze config error: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,97 @@
"""
文件上传接口
"""
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, UploadFile, File, HTTPException, Request
from fastapi.responses import FileResponse
from loguru import logger
from app.schemas import ApiResponse
from app.config import settings
router = APIRouter()
# 上传目录
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
@router.post("/upload", response_model=ApiResponse)
async def upload_file(request: Request, file: UploadFile = File(...)):
"""
上传文件并返回公开访问链接
- 接收前端上传的文件(如简历 PDF
- 保存到本地并生成公开链接
- 返回文件链接供 Coze 工作流使用
"""
try:
# 验证文件类型
if not file.filename.lower().endswith('.pdf'):
raise HTTPException(status_code=400, detail="仅支持 PDF 格式")
# 读取文件内容
content = await file.read()
# 验证文件大小(最大 10MB
if len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
# 生成唯一文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = uuid.uuid4().hex[:8]
safe_filename = f"{timestamp}_{unique_id}.pdf"
# 保存文件
file_path = os.path.join(UPLOAD_DIR, safe_filename)
with open(file_path, "wb") as f:
f.write(content)
logger.info(f"File saved: {file_path}, size: {len(content)} bytes")
# 生成公开访问链接
# 从请求中获取 host
host = request.headers.get("host", "localhost:8000")
scheme = request.headers.get("x-forwarded-proto", "http")
file_url = f"{scheme}://{host}/api/files/{safe_filename}"
logger.info(f"File URL: {file_url}")
return ApiResponse(
code=0,
message="success",
data={
"fileUrl": file_url,
"fileName": file.filename,
"fileSize": len(content),
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Upload error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/files/{filename}")
async def get_file(filename: str):
"""
获取上传的文件(供 Coze 工作流访问)
"""
# 安全检查:防止路径遍历
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
file_path = os.path.join(UPLOAD_DIR, filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
file_path,
media_type="application/pdf",
filename=filename
)