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

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# AI Interview Backend App

55
backend/app/config.py Normal file
View File

@@ -0,0 +1,55 @@
"""
配置管理
"""
import os
from typing import List
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""应用配置"""
# 基础配置
DEBUG: bool = True
API_PORT: int = 8000
# CORS 配置 - 支持环境变量覆盖
CORS_ORIGINS: List[str] = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://interview.test.ai.ireborn.com.cn",
"https://interview.test.ai.ireborn.com.cn"
]
# Coze 配置
COZE_API_BASE: str = "https://api.coze.cn"
COZE_PAT_TOKEN: str = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
COZE_BOT_ID: str = "7595113005181386792"
# Coze 语音配置(从测试结果获取)
COZE_VOICE_ID: str = "7426725529589661723"
# 文件存储配置
UPLOAD_DIR: str = "uploads" # 本地上传文件存储目录(临时)
# 远程文件服务器配置(用于 Coze 工作流访问文件)
FILE_SERVER_UPLOAD_URL: str = "http://files.test.ai.ireborn.com.cn/upload.php"
FILE_SERVER_TOKEN: str = "" # PHP 上传接口的验证令牌
# 公网隧道 URL已弃用改用远程文件服务器
TUNNEL_URL: str = ""
NGROK_URL: str = ""
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = True
def get_settings() -> Settings:
"""获取配置"""
return Settings()
# 直接实例化,每次导入时读取最新 .env
settings = Settings()

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
)

100
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,100 @@
"""
Pydantic 数据模型
"""
from typing import Optional, List, Any
from pydantic import BaseModel, Field
from datetime import datetime
# ============ 通用响应 ============
class ApiResponse(BaseModel):
"""统一 API 响应格式"""
code: int = 0
message: str = "success"
data: Any = None
# ============ 候选人相关 ============
class SubmitCandidateResponse(BaseModel):
"""提交候选人信息响应"""
sessionId: str
fileId: str
class CreateRoomRequest(BaseModel):
"""创建房间请求"""
sessionId: Optional[str] = None # 方案A可选由后端生成
fileId: Optional[str] = None # 方案A可选由工作流收集
class CreateRoomResponse(BaseModel):
"""创建房间响应"""
roomId: str
token: str
appId: str
userId: str
sessionId: Optional[str] = None # 返回给前端用于后续操作
debugInfo: Optional[Any] = None # 调试信息(语音模式)
class EndInterviewResponse(BaseModel):
"""结束面试响应"""
success: bool
class ChatRequest(BaseModel):
"""文本对话请求(模拟语音)"""
sessionId: str
message: str
conversationId: Optional[str] = None
class ChatResponse(BaseModel):
"""文本对话响应"""
reply: str
conversationId: str
debugInfo: Optional[Any] = None # 调试信息(节点状态、消息列表等)
# ============ 管理后台相关 ============
class CandidateScores(BaseModel):
"""候选人评分"""
salesSkill: int = Field(0, ge=0, le=100)
salesMindset: int = Field(0, ge=0, le=100)
quality: int = Field(0, ge=0, le=100)
motivation: int = Field(0, ge=0, le=100)
total: float = Field(0, ge=0, le=100)
class CandidateListItem(BaseModel):
"""候选人列表项"""
sessionId: str
name: str
status: str # pending, ongoing, completed
score: Optional[float] = None
createdAt: str
class CandidateDetail(BaseModel):
"""候选人详情"""
sessionId: str
name: str
resume: Optional[str] = None
status: str
currentStage: int = 0
scores: Optional[CandidateScores] = None
analysis: Optional[str] = None
interviewLog: Optional[str] = None
createdAt: str
completedAt: Optional[str] = None
class CandidateListResponse(BaseModel):
"""候选人列表响应"""
list: List[CandidateListItem]
total: int
page: int
pageSize: int

View File

@@ -0,0 +1,4 @@
# Services
from .coze_service import CozeService, coze_service
__all__ = ["CozeService", "coze_service"]

View File

@@ -0,0 +1,839 @@
"""
Coze API 服务封装
"""
import time
import httpx
from typing import Optional, Dict, Any
from loguru import logger
from app.config import settings
class CozeService:
"""Coze API 服务"""
def __init__(self):
self.base_url = settings.COZE_API_BASE
self.headers = {
"Authorization": f"Bearer {settings.COZE_PAT_TOKEN}",
"Content-Type": "application/json",
}
self.bot_id = settings.COZE_BOT_ID
# 工作流 A 的 ID初始化工作流
INIT_WORKFLOW_ID = "7597357422713798710"
# 工作流 B 的 ID面试工作流
INTERVIEW_WORKFLOW_ID = "7595077233002840079"
async def upload_file(self, file_content: bytes, filename: str) -> Dict[str, Any]:
"""
上传文件到 Coze
Args:
file_content: 文件内容
filename: 文件名
Returns:
{"id": "file_xxx", ...}
"""
url = f"{self.base_url}/v1/files/upload"
async with httpx.AsyncClient(timeout=60.0) as client:
files = {"file": (filename, file_content)}
headers = {"Authorization": f"Bearer {settings.COZE_PAT_TOKEN}"}
response = await client.post(url, files=files, headers=headers)
response.raise_for_status()
data = response.json()
logger.info(f"File uploaded: {data}")
if "data" in data:
return data["data"]
return data
async def get_file_url(self, file_id: str) -> str:
"""
获取 Coze 文件的临时下载链接
Args:
file_id: 文件 ID
Returns:
文件的临时下载 URL
"""
url = f"{self.base_url}/v1/files/retrieve"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
url,
params={"file_id": file_id},
headers=self.headers
)
response.raise_for_status()
data = response.json()
logger.info(f"File retrieve response: {data}")
# 返回文件的临时 URL
file_info = data.get("data", {})
file_url = file_info.get("url", "")
if not file_url:
raise ValueError(f"Failed to get file URL for file_id: {file_id}")
return file_url
async def run_init_workflow(
self,
name: str,
file_url: str,
) -> Dict[str, Any]:
"""
执行初始化工作流工作流A
- 上传简历、解析、创建数据库记录
- 返回 session_id 和调试信息
Args:
name: 候选人姓名
file_url: 简历文件链接
Returns:
{"session_id": "xxx", "raw_response": {...}, "parsed_data": {...}, "debug_url": "..."}
"""
import asyncio
url = f"{self.base_url}/v1/workflow/run"
payload = {
"workflow_id": self.INIT_WORKFLOW_ID,
"parameters": {
"name": name,
"file_url": file_url,
}
}
logger.info(f"Running init workflow with payload: {payload}")
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(url, json=payload, headers=self.headers)
response.raise_for_status()
data = response.json()
logger.info(f"Init workflow response: {data}")
result = {
"session_id": "",
"raw_response": data,
"parsed_data": None,
"debug_url": data.get("debug_url", ""),
"execute_id": data.get("execute_id", ""),
"code": data.get("code"),
"msg": data.get("msg", ""),
}
if data.get("code") == 0:
import json
# execute_id 在顶层
execute_id = data.get("execute_id", "")
# data 字段是工作流输出JSON 字符串)
output_str = data.get("data", "")
result["execute_id"] = execute_id
result["output_str"] = output_str
logger.info(f"Workflow execute_id: {execute_id}")
logger.info(f"Workflow output_str: {output_str}")
# 构建调试链接
result["debug_url"] = f"https://www.coze.cn/work_flow?execute_id={execute_id}&space_id=7516832346776780836&workflow_id={self.INIT_WORKFLOW_ID}&execute_mode=2"
# 解析工作流输出
session_id = None
output_data = None
if output_str:
try:
output_data = json.loads(output_str)
result["parsed_data"] = output_data
logger.info(f"Workflow output_data: {output_data}")
# 尝试从不同格式中提取 session_id
if isinstance(output_data, dict):
# 格式1: {"session_id": "xxx"}
session_id = output_data.get("session_id")
# 格式2: {"data": "SESS_xxx"} - data 直接是 session_id 字符串
if not session_id and "data" in output_data:
inner_data = output_data.get("data")
# 如果 data 是字符串且以 SESS_ 开头,直接使用
if isinstance(inner_data, str) and inner_data.startswith("SESS_"):
session_id = inner_data
elif isinstance(inner_data, str):
# 尝试解析为 JSON
try:
inner_data = json.loads(inner_data)
except:
pass
if isinstance(inner_data, dict):
session_id = inner_data.get("session_id")
elif isinstance(inner_data, list) and len(inner_data) > 0:
session_id = inner_data[0].get("session_id") if isinstance(inner_data[0], dict) else None
elif isinstance(output_data, list) and len(output_data) > 0:
# 格式3: [{"session_id": "xxx"}]
if isinstance(output_data[0], dict):
session_id = output_data[0].get("session_id")
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse workflow output: {e}")
result["parse_error"] = str(e)
# 如果没有 session_id使用 execute_id 作为替代
if not session_id:
logger.warning(f"No session_id in workflow output, using execute_id as session_id")
session_id = f"WF_{execute_id}" if execute_id else f"SESS_{int(time.time())}"
result["session_id_source"] = "execute_id_fallback"
else:
result["session_id_source"] = "workflow_output"
result["session_id"] = session_id
logger.info(f"Final session_id: {session_id}")
return result
else:
error_msg = data.get("msg", "Unknown error")
result["error"] = error_msg
raise ValueError(f"Workflow execution failed: {error_msg}")
async def _wait_for_workflow_result(
self,
execute_id: str,
max_retries: int = 60,
) -> str:
"""
等待工作流执行完成并获取结果
"""
import asyncio
url = f"{self.base_url}/v1/workflow/run_histories"
for i in range(max_retries):
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
url,
params={"execute_id": execute_id},
headers=self.headers
)
if response.status_code == 200:
data = response.json()
logger.info(f"Workflow status [{i}]: {data}")
if data.get("code") == 0:
result_data = data.get("data", {})
status = result_data.get("status", "")
if status == "Success":
output = result_data.get("output", "")
# 解析输出获取 session_id
try:
import json
output_data = json.loads(output)
return output_data.get("session_id", output)
except:
return output
elif status == "Failed":
raise ValueError(f"Workflow failed: {result_data.get('error', 'Unknown')}")
# Running 状态继续等待
await asyncio.sleep(1)
raise TimeoutError("Workflow execution timeout")
async def init_interview(
self,
name: str,
file_content: bytes,
filename: str,
) -> Dict[str, Any]:
"""
完整的面试初始化流程
1. 上传文件到远程服务器
2. 获取公网可访问的 URL
3. 执行初始化工作流
4. 返回 session_id 和调试信息
Args:
name: 候选人姓名
file_content: 简历文件内容
filename: 文件名
Returns:
{"session_id": "xxx", "debug_info": {...}}
"""
debug_info = {
"steps": [],
"timestamps": {},
}
# 1. 上传文件到远程服务器
debug_info["steps"].append("Step 1: Uploading file to remote server")
debug_info["file_size"] = len(file_content)
logger.info(f"Step 1: Uploading file to remote server ({len(file_content)} bytes)...")
# 检查配置
if not settings.FILE_SERVER_TOKEN:
raise ValueError("FILE_SERVER_TOKEN is not configured. Please set FILE_SERVER_TOKEN in .env file.")
# 调用远程 PHP 上传接口
upload_url = settings.FILE_SERVER_UPLOAD_URL
async with httpx.AsyncClient(timeout=60.0) as client:
files = {"file": (filename, file_content, "application/pdf")}
data = {"token": settings.FILE_SERVER_TOKEN}
response = await client.post(upload_url, files=files, data=data)
response.raise_for_status()
upload_result = response.json()
logger.info(f"Upload response: {upload_result}")
if upload_result.get("code") != 0:
error_msg = upload_result.get("error", "Unknown upload error")
raise ValueError(f"File upload failed: {error_msg}")
file_url = upload_result.get("url", "")
file_id = upload_result.get("file_id", "")
debug_info["steps"].append("File uploaded successfully")
debug_info["file_id"] = file_id
debug_info["file_url"] = file_url
debug_info["upload_response"] = upload_result
logger.info(f"Step 1 completed: file_url={file_url}")
# 2. 执行初始化工作流
logger.info(f"Step 2: Running init workflow with name={name}, file_url={file_url}...")
debug_info["steps"].append("Step 2: Running init workflow")
debug_info["workflow_input"] = {"name": name, "file_url": file_url}
workflow_result = await self.run_init_workflow(name, file_url)
# workflow_result 现在返回更多信息
session_id = workflow_result.get("session_id", "")
debug_info["steps"].append("Workflow completed")
debug_info["workflow_response"] = workflow_result.get("raw_response")
debug_info["workflow_data"] = workflow_result.get("parsed_data")
debug_info["debug_url"] = workflow_result.get("debug_url")
logger.info(f"Init workflow completed, session_id: {session_id}")
return {
"session_id": session_id,
"debug_info": debug_info
}
async def create_audio_room(
self,
user_id: str,
file_id: Optional[str] = None,
session_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
创建语音房间,让 Bot 加入
Coze API 会返回完整的 RTC 连接信息:
- app_id: 火山引擎 RTC App ID
- room_id: 房间 ID
- token: RTC Token
- uid: 用户 ID
Args:
user_id: 用户 ID
file_id: 简历文件 ID可选
session_id: 会话 ID可选
Returns:
RTC 连接信息
"""
url = f"{self.base_url}/v1/audio/rooms"
# 将 session_id 作为 user_id 传递,这样工作流可以从 sys_var.user_id 获取
# 如果有 session_id用它作为 user_id否则用原始 user_id
actual_user_id = session_id if session_id else user_id
payload = {
"bot_id": self.bot_id,
"user_id": actual_user_id, # session_id 作为 user_id
}
# 添加语音 ID
if settings.COZE_VOICE_ID:
payload["voice_id"] = settings.COZE_VOICE_ID
# ========== 尝试通过多种方式传递 session_id ==========
# 方式 1: parameters类似工作流 API
if session_id:
payload["parameters"] = {
"session_id": session_id,
}
# 方式 2: custom_variables类似对话 API
if session_id:
payload["custom_variables"] = {
"session_id": session_id,
}
# 方式 3: connector_id / extra_info备用
if session_id:
payload["extra_info"] = {
"session_id": session_id,
}
# 方式 4: config 对象
config = {}
if file_id:
config["input_file_id"] = file_id
if session_id:
config["session_id"] = session_id
config["parameters"] = {"session_id": session_id}
if config:
payload["config"] = config
logger.info(f"Creating audio room with payload: {payload}")
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, json=payload, headers=self.headers)
response.raise_for_status()
data = response.json()
logger.info(f"Audio room created: {data}")
result = data.get("data", data)
# 添加调试信息(避免循环引用,不包含 data 字段)
result["debug_info"] = {
"session_id": session_id,
"actual_user_id": actual_user_id,
"bot_id": self.bot_id,
"request_payload": payload, # 完整的请求参数(包括所有尝试的字段)
"coze_code": data.get("code"),
"coze_msg": data.get("msg"),
"coze_logid": data.get("detail", {}).get("logid"),
"coze_bot_url": f"https://www.coze.cn/space/7516832346776780836/bot/{self.bot_id}",
"search_hint": f"工作流可通过 {{{{sys_var.user_id}}}} 获取 session_id: {actual_user_id}",
"tried_methods": [
"user_id (as session_id)",
"parameters.session_id",
"custom_variables.session_id",
"extra_info.session_id",
"config.session_id",
"config.parameters.session_id",
],
}
return result
async def chat_via_workflow(
self,
session_id: str,
message: str,
workflow_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
通过工作流 API 进行文字对话(推荐用于文字面试模式)
使用 /v1/workflow/run API 直接调用工作流 B
可以正确传递 session_id 等必填参数。
Args:
session_id: 会话 ID来自工作流 A
message: 用户消息
workflow_id: 工作流 ID可选默认使用 INTERVIEW_WORKFLOW_ID
Returns:
{"reply": "AI回复"}
"""
import json
wf_id = workflow_id or self.INTERVIEW_WORKFLOW_ID
if not wf_id:
raise ValueError("工作流 B 的 ID 未配置,请设置 INTERVIEW_WORKFLOW_ID")
url = f"{self.base_url}/v1/workflow/run"
payload = {
"workflow_id": wf_id,
"parameters": {
"session_id": session_id,
"USER_INPUT": message, # 用户输入
}
}
logger.info(f"Workflow chat request: {payload}")
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(url, json=payload, headers=self.headers)
response.raise_for_status()
data = response.json()
logger.info(f"Workflow chat response: {data}")
if data.get("code") == 0:
# 解析工作流输出
output_str = data.get("data", "")
try:
if output_str:
output_data = json.loads(output_str)
# 尝试提取回复内容
if isinstance(output_data, dict):
reply = output_data.get("reply") or output_data.get("output") or output_data.get("data", "")
if isinstance(reply, str):
return {"reply": reply}
elif isinstance(reply, dict):
return {"reply": reply.get("content", str(reply))}
elif isinstance(output_data, str):
return {"reply": output_data}
except json.JSONDecodeError:
# 如果不是 JSON直接返回原始字符串
return {"reply": output_str}
return {"reply": output_str or "工作流执行完成"}
else:
error_msg = data.get("msg", "Unknown error")
raise ValueError(f"Workflow execution failed: {error_msg}")
async def chat(
self,
message: str,
user_id: str,
conversation_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
文本对话 - 使用 /v1/workflows/chat 专用接口
这个接口专门用于 Chatflow支持
1. 通过 parameters 传递自定义参数(如 session_id
2. 正确维持问答节点的对话状态
Args:
message: 用户消息
user_id: session_id面试会话 ID
conversation_id: 对话 ID用于多轮对话
Returns:
{"reply": "AI回复", "conversation_id": "xxx"}
"""
import asyncio
# 尝试使用 /v1/workflows/chat 专用接口
# 工作流 B 的 IDChatflow
workflow_id = "7595077233002840079"
url = f"{self.base_url}/v1/workflows/chat"
# 构建 payload
payload = {
"workflow_id": workflow_id,
"user_id": user_id,
"stream": False,
"additional_messages": [
{
"role": "user",
"content": message,
"content_type": "text",
}
],
# 通过 parameters 显式传递 session_id
"parameters": {
"session_id": user_id,
},
}
# 传递 conversation_id 延续对话
if conversation_id:
payload["conversation_id"] = conversation_id
logger.info(f"[Workflows/Chat] request: session_id={user_id}, conv_id={conversation_id}, message={message[:50]}...")
logger.debug(f"[Workflows/Chat] payload: {payload}")
async with httpx.AsyncClient(timeout=120.0) as client:
try:
response = await client.post(url, json=payload, headers=self.headers)
# 检查响应状态
logger.info(f"[Workflows/Chat] status: {response.status_code}, text: {response.text[:200] if response.text else 'empty'}")
if response.status_code != 200 or not response.text:
logger.warning(f"[Workflows/Chat] failed (status={response.status_code}), falling back to /v3/chat")
return await self._chat_v3(message, user_id, conversation_id)
data = response.json()
logger.info(f"[Workflows/Chat] response: code={data.get('code')}, msg={data.get('msg', '')}")
if data.get("code") != 0:
# 如果 /v1/workflows/chat 失败,回退到 /v3/chat
logger.warning(f"[Workflows/Chat] API error, falling back to /v3/chat")
return await self._chat_v3(message, user_id, conversation_id)
except Exception as e:
logger.warning(f"[Workflows/Chat] exception: {e}, falling back to /v3/chat")
return await self._chat_v3(message, user_id, conversation_id)
# 解析响应
chat_data = data.get("data", {})
conv_id = chat_data.get("conversation_id", "")
chat_id = chat_data.get("id", "")
logger.info(f"[Workflows/Chat] started: conv_id={conv_id}, chat_id={chat_id}")
# 轮询等待回复完成
if chat_id and conv_id:
result = await self._wait_for_reply(conv_id, chat_id)
return {
"reply": result.get("reply", ""),
"conversation_id": conv_id,
"debug_info": result.get("debug_info", {}),
}
return {
"reply": "抱歉,我没有理解您的意思,请再说一次。",
"conversation_id": conversation_id or "",
"debug_info": {"error": "Workflows/Chat API error"},
}
async def _chat_v3(
self,
message: str,
user_id: str,
conversation_id: Optional[str] = None,
) -> Dict[str, Any]:
"""
备用方法:使用 /v3/chat API
"""
url = f"{self.base_url}/v3/chat"
# 构建消息内容:嵌入 session_id
if not conversation_id:
content = f"[SESSION:{user_id}]\n{message}"
else:
content = message
payload = {
"bot_id": self.bot_id,
"user_id": user_id,
"stream": False,
"auto_save_history": True,
"additional_messages": [
{
"role": "user",
"content": content,
"content_type": "text",
}
],
}
if conversation_id:
payload["conversation_id"] = conversation_id
logger.info(f"[v3/chat] request: user_id={user_id}, conv_id={conversation_id}")
logger.debug(f"[v3/chat] payload: {payload}")
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(url, json=payload, headers=self.headers)
response.raise_for_status()
data = response.json()
logger.info(f"[v3/chat] response: code={data.get('code')}")
if data.get("code") == 0:
chat_data = data.get("data", {})
conv_id = chat_data.get("conversation_id", "")
chat_id = chat_data.get("id", "")
logger.info(f"[v3/chat] started: conv_id={conv_id}, chat_id={chat_id}")
if chat_id and conv_id:
result = await self._wait_for_reply(conv_id, chat_id)
# 构建 Coze 后台查询链接
coze_debug_url = f"https://www.coze.cn/space/7516832346776780836/bot/{self.bot_id}"
debug_info = result.get("debug_info", {})
debug_info.update({
"conversation_id": conv_id,
"chat_id": chat_id,
"session_id": user_id,
"coze_bot_url": coze_debug_url,
"search_hint": f"在 Coze 后台搜索 conversation_id: {conv_id} 或 user_id: {user_id}",
})
return {
"reply": result.get("reply", ""),
"conversation_id": conv_id,
"debug_info": debug_info,
}
else:
error_msg = data.get("msg", "Unknown error")
logger.error(f"[v3/chat] API error: {error_msg}")
return {
"reply": "抱歉,我没有理解您的意思,请再说一次。",
"conversation_id": conversation_id or "",
"debug_info": {"error": "v3/chat API error"},
}
async def _wait_for_reply(
self,
conversation_id: str,
chat_id: str,
max_retries: int = 30,
) -> dict:
"""
等待 AI 回复完成
Returns:
dict: {"reply": str, "debug_info": dict}
"""
import asyncio
import json
url = f"{self.base_url}/v3/chat/retrieve"
debug_info = {
"status_history": [],
"messages": [],
"raw_responses": [],
}
for i in range(max_retries):
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
url,
params={
"conversation_id": conversation_id,
"chat_id": chat_id,
},
headers=self.headers
)
if response.status_code == 200:
data = response.json()
# 记录原始响应(用于调试)
debug_info["raw_responses"].append({
"iteration": i,
"data": data
})
if data.get("code") == 0:
chat_data = data.get("data", {})
status = chat_data.get("status", "")
# 记录状态历史
status_info = {
"iteration": i,
"status": status,
"required_action": chat_data.get("required_action"),
}
debug_info["status_history"].append(status_info)
logger.info(f"Chat status [{i}]: {status}")
# 打印详细的节点信息
if chat_data.get("required_action"):
action = chat_data.get("required_action")
logger.info(f"🔔 Required action: {json.dumps(action, ensure_ascii=False, indent=2)}")
if status == "completed":
# 获取消息列表
messages = await self._get_messages(conversation_id, chat_id)
debug_info["messages"] = messages
# 找到 AI 的回复
for msg in reversed(messages):
if msg.get("role") == "assistant" and msg.get("type") == "answer":
return {
"reply": msg.get("content", ""),
"debug_info": debug_info,
}
return {
"reply": "面试官正在思考...",
"debug_info": debug_info,
}
elif status == "failed":
return {
"reply": f"抱歉,出现了一些问题:{chat_data.get('last_error', {}).get('msg', '未知错误')}",
"debug_info": debug_info,
}
elif status == "requires_action":
# 工作流需要用户输入question 节点或文件上传节点)
messages = await self._get_messages(conversation_id, chat_id)
debug_info["messages"] = messages
# 打印所有消息用于调试
logger.info(f"📨 Messages ({len(messages)} total):")
for idx, msg in enumerate(messages):
msg_type = msg.get("type", "unknown")
msg_role = msg.get("role", "unknown")
msg_content = msg.get("content", "")[:200]
logger.info(f" [{idx}] {msg_role}/{msg_type}: {msg_content}")
# 检查是否有文件上传请求
for msg in messages:
if msg.get("type") == "tool_call":
logger.info(f"🔧 Tool call detected: {msg.get('content', '')}")
# 返回 AI 的问题
for msg in reversed(messages):
if msg.get("role") == "assistant" and msg.get("type") == "answer":
content = msg.get("content", "")
if content:
return {
"reply": content,
"debug_info": debug_info,
}
return {
"reply": "请回答上面的问题...",
"debug_info": debug_info,
}
# 其他状态in_progress, created继续等待
await asyncio.sleep(1)
return {
"reply": "响应超时,请重试。",
"debug_info": debug_info,
}
async def _get_messages(
self,
conversation_id: str,
chat_id: str,
) -> list:
"""
获取对话消息列表
"""
url = f"{self.base_url}/v3/chat/message/list"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
url,
params={
"conversation_id": conversation_id,
"chat_id": chat_id,
},
headers=self.headers
)
if response.status_code == 200:
data = response.json()
logger.info(f"Messages response: {data}")
if data.get("code") == 0:
return data.get("data", [])
return []
# 创建单例
coze_service = CozeService()

40
backend/check_table.py Normal file
View File

@@ -0,0 +1,40 @@
"""查询表现有数据的结构"""
import asyncio
import httpx
import json
COZE_API_BASE = "https://api.coze.cn"
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
WORKFLOW_ID = "7597376294612107318"
async def query(table: str, sql: str):
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json"
}
input_json = json.dumps({"table": table, "sql": sql}, ensure_ascii=False)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{COZE_API_BASE}/v1/workflow/run",
headers=headers,
json={"workflow_id": WORKFLOW_ID, "parameters": {"input": input_json}}
)
result = response.json()
print(f"\n{table}:")
print(f" code: {result.get('code')}")
if result.get('data'):
data = json.loads(result.get('data', '{}'))
output = data.get('output', [])
if output and len(output) > 0:
print(f" 列名: {list(output[0].keys())}")
else:
print(f" 空数据")
async def main():
print("查询表结构...")
await query("assessments", "SELECT * FROM ci_interview_assessments LIMIT 1")
await query("logs", "SELECT * FROM ci_interview_logs LIMIT 1")
if __name__ == "__main__":
asyncio.run(main())

137
backend/insert_full_mock.py Normal file
View File

@@ -0,0 +1,137 @@
"""
插入包含完整评分字段的 Mock 数据
"""
import asyncio
import httpx
import json
import uuid
import random
COZE_PAT_TOKEN = 'pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT'
WORKFLOW_ID = '7597376294612107318'
CANDIDATES = [
{'name': '周雪琴', 'base_score': 88},
{'name': '陈美华', 'base_score': 75},
{'name': '林婷婷', 'base_score': 92},
{'name': '黄丽萍', 'base_score': 65},
{'name': '吴晓燕', 'base_score': 82},
]
async def execute_sql(sql: str, table: str) -> dict:
headers = {
'Authorization': f'Bearer {COZE_PAT_TOKEN}',
'Content-Type': 'application/json'
}
payload = {
'workflow_id': WORKFLOW_ID,
'parameters': {
'input': json.dumps({'table': table, 'sql': sql}, ensure_ascii=False)
}
}
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post('https://api.coze.cn/v1/workflow/run', headers=headers, json=payload)
return r.json()
def gen_score(base: int, variance: int = 10) -> int:
return max(50, min(100, base + random.randint(-variance, variance)))
def get_level(score: int) -> str:
if score >= 85:
return "优秀"
elif score >= 70:
return "良好"
else:
return "一般"
def escape_sql(s: str) -> str:
return s.replace("'", "''")
async def main():
print('插入包含完整评分的 Mock 数据...')
print('=' * 50)
for idx, c in enumerate(CANDIDATES, 1):
session_id = f'MOCK_{uuid.uuid4().hex[:8].upper()}'
base = c['base_score']
name = c['name']
# 生成各维度分数
skill_score = gen_score(base)
concept_score = gen_score(base)
competency_score = gen_score(base)
avg_score = round((skill_score + concept_score + competency_score) / 3)
# 生成报告
skill_level = get_level(skill_score)
concept_level = get_level(concept_score)
competency_level = get_level(competency_score)
skill_report = f"销售技能评估:该候选人展现出{skill_level}的销售技巧。得分 {skill_score} 分。"
concept_report = f"销售观念评估:候选人对销售工作的认知{concept_level}。得分 {concept_score} 分。"
competency_report = f"综合素质评估:候选人的学习能力和抗压能力{competency_level}。得分 {competency_score} 分。"
recommend = "强烈推荐录用" if avg_score >= 85 else ("建议录用" if avg_score >= 70 else "建议观察")
final_report = f"""## 面试评估报告
**候选人**: {name}
**综合评分**: {avg_score}/100
### 各维度评分
- 销售技能: {skill_score}
- 销售观念: {concept_score}
- 综合素质: {competency_score}
### 建议
{recommend}
"""
# 完整的 INSERT 语句
sql = f"""INSERT INTO ci_interview_assessments (
session_id, candidate_name, current_stage,
sales_skill_score, sales_skill_report,
sales_concept_score, sales_concept_report,
competency_score, competency_report,
final_score_report
) VALUES (
'{session_id}', '{name}', 'completed',
'{skill_score}', '{escape_sql(skill_report)}',
'{concept_score}', '{escape_sql(concept_report)}',
'{competency_score}', '{escape_sql(competency_report)}',
'{escape_sql(final_report)}'
)"""
result = await execute_sql(sql, 'assessments')
status = '' if result.get('code') == 0 else f"❌ code={result.get('code')}"
print(f'{idx}. {name}: 技能{skill_score} 观念{concept_score} 素质{competency_score} -> {status}')
# 插入对话记录
dialogues = [
('销售技能', '请描述一次成功的销售经历', '我曾经成功说服一位犹豫的客户购买了我们的高端护肤套装,通过了解她的肤质问题,提供了针对性的方案。'),
('销售观念', '您认为什么是好的销售', '好的销售是真正帮助客户解决问题,建立长期信任关系,而不是一次性交易。'),
('综合素质', '遇到困难时您如何应对', '我会先冷静分析问题的原因,制定解决方案,并保持积极的心态去执行。'),
]
for j, (stage, q, a) in enumerate(dialogues, 1):
log_id = f'LOG_{session_id}_{j:02d}'
log_sql = f"""INSERT INTO ci_interview_logs (
log_id, session_id, stage, round, ai_question, user_answer, log_type
) VALUES (
'{log_id}', '{session_id}', '{stage}', '{j}',
'{escape_sql(q)}', '{escape_sql(a)}', 'interview'
)"""
await execute_sql(log_sql, 'logs')
await asyncio.sleep(0.3)
print('=' * 50)
print('✅ 完成!')
if __name__ == '__main__':
asyncio.run(main())

261
backend/insert_mock_data.py Normal file
View File

@@ -0,0 +1,261 @@
"""
插入模拟面试数据到 Coze 数据库
通过工作流 C1 执行 SQL INSERT 语句
"""
import asyncio
import httpx
import json
import random
from datetime import datetime, timedelta
# Coze 配置
COZE_API_BASE = "https://api.coze.cn"
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
WORKFLOW_ID = "7597376294612107318" # 查询工作流 ID
# 模拟候选人数据
MOCK_CANDIDATES = [
{"name": "张小美", "score_base": 85},
{"name": "李明辉", "score_base": 78},
{"name": "王晓丽", "score_base": 92},
{"name": "陈建国", "score_base": 65},
{"name": "刘芳芳", "score_base": 72},
{"name": "赵大伟", "score_base": 58},
{"name": "孙婷婷", "score_base": 88},
{"name": "周志强", "score_base": 45},
]
# 模拟简历内容
MOCK_RESUMES = [
"3年销售经验曾在某知名医美机构担任销售顾问业绩连续12个月达成率超过120%。熟悉轻医美产品线,擅长客户关系维护。",
"5年美容行业从业经验持有高级美容师证书。性格开朗沟通能力强善于发现客户需求并提供专业建议。",
"应届毕业生,市场营销专业,在校期间有丰富的社团活动经验。对医美行业充满热情,学习能力强。",
"2年电商销售经验熟悉线上运营和客户服务。希望转型到线下医美销售领域有较强的目标感和执行力。",
]
# 模拟评估报告模板
SKILL_REPORTS = [
"候选人展现出扎实的销售基础,在产品介绍和需求挖掘方面表现突出。能够灵活运用 FAB 法则,善于建立客户信任。建议加强异议处理技巧的训练。得分:{score}",
"销售技能较为全面,尤其在客户沟通和关系建立方面有独到之处。能够准确把握客户心理,提供针对性的解决方案。需要在成交技巧上进一步打磨。得分:{score}",
"基础销售技能掌握良好,但缺乏实战经验。理论知识扎实,需要更多的实践机会来提升。建议安排老带新的培训模式。得分:{score}",
]
CONCEPT_REPORTS = [
"对销售工作有正确的认知,理解以客户为中心的服务理念。能够平衡业绩压力和客户满意度,具有长期发展的潜力。得分:{score}",
"销售观念较为成熟,认同医美行业的价值观。注重专业性和诚信度,能够为客户提供真诚的建议。得分:{score}",
"对销售工作的理解还停留在表面,需要加强对行业和产品的深入学习。建议多参与案例分析和行业培训。得分:{score}",
]
COMPETENCY_REPORTS = [
"综合素质优秀,具备良好的学习能力和抗压能力。工作态度积极,团队协作意识强。是值得培养的优秀人才。得分:{score}",
"整体素质良好,有较强的责任心和执行力。沟通表达清晰,形象气质佳。建议加强时间管理能力。得分:{score}",
"基本素质合格,但在主动性和自驱力方面有提升空间。需要更多的激励和引导来激发潜力。得分:{score}",
]
MOTIVATION_SUMMARIES = [
"候选人对轻医美行业表现出浓厚的兴趣希望在这个领域长期发展。主要动机包括1看好行业发展前景2认同公司品牌价值3期待获得专业成长机会。",
"求职动机明确,主要是希望获得更好的收入和职业发展平台。对销售工作有热情,愿意接受挑战和压力。",
"动机较为单一,主要关注薪资待遇。建议在后续面试中进一步了解其对行业的认知和长期规划。",
]
RISK_WARNINGS = [
"", # 无风险
"", # 无风险
"候选人在上一份工作中存在频繁跳槽的情况,需要关注稳定性问题。建议在背调中重点核实离职原因。",
"面试过程中发现候选人对公司产品了解不够深入,可能存在准备不充分的情况。建议安排二次面试进一步评估。",
"候选人期望薪资与市场水平存在一定差距,需要在 Offer 阶段进行合理沟通。",
]
GROWTH_PLANS = [
"建议培养方向1前3个月重点学习产品知识和销售流程23-6个月参与实战由资深顾问带教36个月后独立负责客户。预计1年内可成长为骨干销售。",
"该候选人具备快速成长的潜力建议1入职即安排系统培训2重点培养其客户开发能力3可考虑作为储备管理人才培养。",
"成长空间有限建议先观察3个月试用期表现再做进一步评估。",
]
REF_CHECK_LISTS = [
"建议背调问题:\n1. 请确认候选人在贵公司的任职时间和职位\n2. 候选人的主要工作职责是什么?\n3. 您如何评价候选人的销售能力?\n4. 候选人的离职原因是什么?\n5. 如果有机会,您是否愿意再次与其共事?",
"重点背调事项:\n1. 核实业绩数据的真实性\n2. 了解团队协作情况\n3. 确认是否有劳动纠纷\n4. 验证学历和证书的真实性",
]
# 模拟对话记录
MOCK_DIALOGUES = [
{
"stage": "开场",
"ai_question": "您好!欢迎参加我们的 AI 面试。我是您的 AI 面试官,接下来我们将进行一次关于销售岗位的面试。首先,请您简单介绍一下自己。",
"user_answer": "您好,我叫{name}今年28岁。我有3年的销售工作经验主要在美容行业从事客户服务和销售工作。我性格开朗善于与人沟通对轻医美行业非常感兴趣。"
},
{
"stage": "销售技能",
"ai_question": "很好,感谢您的自我介绍。接下来我想了解一下您的销售经验。请您描述一次成功的销售案例,包括您是如何发现客户需求、如何推荐产品、以及最终如何促成交易的。",
"user_answer": "好的。去年我接待了一位40多岁的女士她最初只是来咨询皮肤保养。通过聊天我发现她其实对法令纹比较在意。我没有直接推销而是先帮她做了皮肤检测用数据说明问题。然后根据她的预算和需求推荐了适合的玻尿酸填充方案。整个过程我注重建立信任最终她不仅做了法令纹填充还办了年卡。"
},
{
"stage": "销售技能",
"ai_question": "非常好的案例分享。那么当客户对价格有异议时,您通常如何处理?",
"user_answer": "价格异议是很常见的。我通常会先认同客户的感受,然后分析价值而不是价格。比如我会说'您的担心我理解选择医美确实需要慎重考虑。不过您看我们使用的是进口正品由资深医生操作术后还有专业跟踪服务。很多客户反馈效果能保持1-2年算下来其实性价比很高。'另外我也会提供分期付款等灵活方案。"
},
{
"stage": "销售观",
"ai_question": "您如何看待销售工作?您认为一个优秀的销售顾问最重要的品质是什么?",
"user_answer": "我认为销售的本质是帮助客户解决问题,而不是单纯的推销产品。一个优秀的销售顾问首先要专业,要真正了解产品和客户需求;其次要真诚,不能为了业绩误导客户;最后要有服务意识,把每一位客户都当作长期朋友来维护。"
},
{
"stage": "素质项",
"ai_question": "假设您在工作中遇到了连续三个月业绩不达标的情况,您会如何应对?",
"user_answer": "首先我会分析原因,是市场问题、个人方法问题还是其他因素。然后我会主动向业绩好的同事请教,学习他们的成功经验。同时我会调整自己的工作方法,比如增加客户回访频率、优化话术等。最重要的是保持积极心态,相信通过努力一定能改善。"
},
{
"stage": "求职动机",
"ai_question": "最后一个问题,您为什么选择我们公司?您对未来的职业发展有什么规划?",
"user_answer": "选择贵公司主要有三个原因一是贵公司在轻医美领域有很好的口碑和品牌影响力二是听说公司非常注重员工培训和成长三是公司的企业文化和我的价值观很匹配。未来我希望能在1-2年内成长为资深销售顾问3年内有机会带领小团队为公司创造更大价值。"
},
]
async def execute_sql(sql: str, table: str) -> dict:
"""通过工作流执行 SQL"""
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json"
}
payload = {
"workflow_id": WORKFLOW_ID,
"parameters": {
"input": json.dumps({"table": table, "sql": sql}, ensure_ascii=False)
}
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{COZE_API_BASE}/v1/workflow/run",
headers=headers,
json=payload
)
return response.json()
def generate_session_id(name: str, idx: int) -> str:
"""生成 session_id"""
timestamp = datetime.now().strftime("%Y%m%d%H%M")
return f"SESS_{timestamp}_{name}_{idx:02d}"
def generate_assessment_sql(session_id: str, candidate: dict, idx: int) -> str:
"""生成评估记录 INSERT SQL"""
base = candidate["score_base"]
variation = random.randint(-5, 10)
skill_score = min(100, max(0, base + random.randint(-8, 8)))
concept_score = min(100, max(0, base + random.randint(-5, 10)))
competency_score = min(100, max(0, base + random.randint(-10, 5)))
skill_report = random.choice(SKILL_REPORTS).format(score=skill_score)
concept_report = random.choice(CONCEPT_REPORTS).format(score=concept_score)
competency_report = random.choice(COMPETENCY_REPORTS).format(score=competency_score)
motivation = random.choice(MOTIVATION_SUMMARIES)
risk = random.choice(RISK_WARNINGS)
growth = random.choice(GROWTH_PLANS)
ref_check = random.choice(REF_CHECK_LISTS)
resume = random.choice(MOCK_RESUMES)
# 转义单引号
def escape(s):
return s.replace("'", "''") if s else ""
sql = f"""INSERT INTO ci_interview_assessments (
session_id, candidate_name, current_stage,
sales_skill_score, sales_skill_report,
sales_concept_score, sales_concept_report,
competency_score, competency_report,
motivation_summary, risk_warning, growth_plan, ref_check_list, resume_text
) VALUES (
'{session_id}', '{candidate["name"]}', '10',
'{skill_score}', '{escape(skill_report)}',
'{concept_score}', '{escape(concept_report)}',
'{competency_score}', '{escape(competency_report)}',
'{escape(motivation)}', '{escape(risk)}', '{escape(growth)}', '{escape(ref_check)}', '{escape(resume)}'
)"""
return sql
def generate_log_sql(session_id: str, candidate_name: str, round_num: int, dialogue: dict, idx: int) -> str:
"""生成对话记录 INSERT SQL"""
import uuid
def escape(s):
return s.replace("'", "''").replace("{name}", candidate_name) if s else ""
# 生成唯一的 log_id
log_id = f"LOG_{idx:02d}_{round_num:02d}_{uuid.uuid4().hex[:8]}"
sql = f"""INSERT INTO ci_interview_logs (
log_id, session_id, stage, round, ai_question, user_answer, log_type
) VALUES (
'{log_id}', '{session_id}', '{dialogue["stage"]}', '{round_num}',
'{escape(dialogue["ai_question"])}', '{escape(dialogue["user_answer"])}', 'interview'
)"""
return sql
async def main():
print("=" * 60)
print("开始插入模拟面试数据...")
print("=" * 60)
success_count = 0
error_count = 0
for idx, candidate in enumerate(MOCK_CANDIDATES):
session_id = generate_session_id(candidate["name"], idx)
print(f"\n[{idx + 1}/{len(MOCK_CANDIDATES)}] 处理候选人: {candidate['name']}")
print(f" Session ID: {session_id}")
# 1. 插入评估记录
try:
assessment_sql = generate_assessment_sql(session_id, candidate, idx)
print(f" 插入评估记录...")
result = await execute_sql(assessment_sql, "assessments")
if result.get("code") == 0:
print(f" ✓ 评估记录插入成功")
success_count += 1
else:
print(f" ✗ 评估记录插入失败: {result}")
error_count += 1
except Exception as e:
print(f" ✗ 评估记录插入异常: {e}")
error_count += 1
# 2. 插入对话记录
for round_num, dialogue in enumerate(MOCK_DIALOGUES, 1):
try:
log_sql = generate_log_sql(session_id, candidate["name"], round_num, dialogue, idx)
print(f" 插入对话记录 (第{round_num}轮)...")
result = await execute_sql(log_sql, "logs")
if result.get("code") == 0:
print(f" ✓ 对话记录 {round_num} 插入成功")
success_count += 1
else:
print(f" ✗ 对话记录 {round_num} 插入失败: {result}")
error_count += 1
except Exception as e:
print(f" ✗ 对话记录 {round_num} 插入异常: {e}")
error_count += 1
# 等待一下避免请求过快
await asyncio.sleep(1)
print("\n" + "=" * 60)
print(f"数据插入完成!")
print(f"成功: {success_count}")
print(f"失败: {error_count}")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

123
backend/insert_mock_full.py Normal file
View File

@@ -0,0 +1,123 @@
"""
插入完整模拟面试数据8 条候选人 + 对话记录)
"""
import asyncio
import httpx
import json
import uuid
COZE_API_BASE = "https://api.coze.cn"
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
WORKFLOW_ID = "7597376294612107318"
# 候选人数据
CANDIDATES = [
{"name": "张小美", "skill": 85, "concept": 82, "comp": 88, "status": "completed", "risk": ""},
{"name": "李明辉", "skill": 72, "concept": 70, "comp": 75, "status": "completed", "risk": ""},
{"name": "王晓丽", "skill": 92, "concept": 90, "comp": 95, "status": "completed", "risk": ""},
{"name": "陈建国", "skill": 65, "concept": 60, "comp": 68, "status": "completed", "risk": "面试中表现紧张,需关注"},
{"name": "刘芳芳", "skill": 78, "concept": 75, "comp": 80, "status": "completed", "risk": ""},
{"name": "赵大伟", "skill": 55, "concept": 52, "comp": 58, "status": "completed", "risk": "销售经验不足,需重点培训"},
{"name": "孙婷婷", "skill": 88, "concept": 85, "comp": 90, "status": "in_progress", "risk": ""},
{"name": "周志强", "skill": 45, "concept": 42, "comp": 48, "status": "completed", "risk": "不建议录用,综合能力较弱"},
]
# 简历模板
RESUMES = [
"3年销售经验曾在某知名医美机构担任销售顾问业绩连续12个月达成率超过120%",
"5年美容行业从业经验持有高级美容师证书。性格开朗沟通能力强。",
"应届毕业生,市场营销专业,在校期间有丰富的社团活动经验。对医美行业充满热情。",
"2年电商销售经验熟悉线上运营和客户服务。希望转型到线下医美销售领域。",
]
# 对话模板
DIALOGUES = [
("开场", "你好,请先做个自我介绍", "面试官您好,我是{name},很高兴参加这次面试。我之前有销售相关经验,对轻医美行业很感兴趣。"),
("技能", "请介绍你常用的销售技巧", "我主要采用顾问式销售方法,先通过沟通了解客户的真实需求,然后针对性地推荐适合的产品或服务。"),
("技能", "遇到客户异议时如何处理", "首先我会认真倾听客户的顾虑,表示理解。然后用专业知识解答疑问,必要时提供案例或数据支持。"),
("观念", "你如何看待销售这份工作", "我认为销售不仅是卖产品,更是帮助客户解决问题。好的销售是客户的顾问和朋友。"),
("素质", "你的职业规划是什么", "短期希望成为一名优秀的销售顾问,中期目标是带领团队,长期希望在医美行业有深入发展。"),
]
async def execute_sql(sql: str, table: str) -> dict:
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json"
}
input_json = json.dumps({"table": table, "sql": sql}, ensure_ascii=False)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{COZE_API_BASE}/v1/workflow/run",
headers=headers,
json={"workflow_id": WORKFLOW_ID, "parameters": {"input": input_json}}
)
return response.json()
def escape(s):
"""转义 SQL 特殊字符"""
return s.replace("'", "''") if s else ""
async def main():
print("=" * 60)
print("插入完整模拟数据 (8 条候选人)")
print("=" * 60)
for idx, c in enumerate(CANDIDATES):
session_id = f"MOCK_{uuid.uuid4().hex[:8].upper()}"
resume = RESUMES[idx % len(RESUMES)]
# 生成报告
skill_report = f"销售技能评估:候选人在产品介绍和需求挖掘方面{'表现出色' if c['skill'] >= 80 else '有待提升'}。得分:{c['skill']}"
concept_report = f"销售观念评估:对销售工作{'有正确认知' if c['concept'] >= 70 else '认识不够深入'}。得分:{c['concept']}"
comp_report = f"综合素质评估:学习能力{'' if c['comp'] >= 80 else '一般'},抗压能力{'' if c['comp'] >= 75 else '需加强'}。得分:{c['comp']}"
# 1. 插入 Assessment
sql1 = f"""INSERT INTO ci_interview_assessments (
session_id, candidate_name, resume_text, current_stage,
sales_skill_score, sales_concept_score, competency_score,
sales_skill_report, sales_concept_report, competency_report,
motivation_summary, risk_warning, growth_plan, ref_check_list
) VALUES (
'{session_id}', '{c["name"]}', '{escape(resume)}', '{c["status"]}',
'{c["skill"]}', '{c["concept"]}', '{c["comp"]}',
'{escape(skill_report)}', '{escape(concept_report)}', '{escape(comp_report)}',
'候选人对轻医美行业表现出兴趣,希望长期发展。',
'{escape(c["risk"])}',
'建议入职后进行系统培训,由资深顾问带教。',
'建议背调:确认任职时间、业绩数据、离职原因。'
)"""
print(f"\n[{idx+1}/8] {c['name']} (session: {session_id})")
result = await execute_sql(sql1, "assessments")
if result.get('code') != 0:
print(f" ❌ Assessment 失败: {result.get('msg')}")
continue
print(f" ✅ Assessment: {c['skill']}/{c['concept']}/{c['comp']}")
# 2. 插入对话记录
for d_idx, (stage, q, a) in enumerate(DIALOGUES, 1):
log_id = f"LOG_{session_id}_{d_idx:02d}"
answer = a.replace("{name}", c["name"])
sql2 = f"""INSERT INTO ci_interview_logs (
log_id, session_id, stage, round, ai_question, user_answer, log_type
) VALUES (
'{log_id}', '{session_id}', '{stage}', '{d_idx}',
'{escape(q)}', '{escape(answer)}', 'interview'
)"""
result = await execute_sql(sql2, "logs")
if result.get('code') != 0:
print(f" ⚠️ 对话{d_idx}: {result.get('msg')[:30]}")
print(f" ✅ 对话: {len(DIALOGUES)}")
print("\n" + "=" * 60)
print("✅ 完成!共插入 8 条候选人 + 40 条对话记录")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,100 @@
"""
简化版 Mock 数据插入脚本 - 只插入2条记录
"""
import asyncio
import httpx
import json
import uuid
import random
COZE_API_BASE = "https://api.coze.cn"
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
WORKFLOW_ID = "7597376294612107318"
# 模拟候选人
CANDIDATES = [
{"name": "张小美", "score": 85, "status": "completed", "risk": "low"},
{"name": "李明辉", "score": 72, "status": "completed", "risk": "medium"},
]
# 简化对话
DIALOGUES = [
{"stage": "专业技能", "q": "请介绍您的护肤专业知识", "a": "我在医美行业有3年经验..."},
{"stage": "沟通能力", "q": "遇到客户投诉如何处理", "a": "首先我会认真倾听客户的问题..."},
]
async def execute_sql(sql: str, table: str) -> dict:
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json"
}
payload = {
"workflow_id": WORKFLOW_ID,
"parameters": {"input": json.dumps({"table": table, "sql": sql}, ensure_ascii=False)}
}
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(f"{COZE_API_BASE}/v1/workflow/run", headers=headers, json=payload)
return r.json()
async def main():
print("=" * 50)
print("插入 Mock 数据")
print("=" * 50)
for idx, c in enumerate(CANDIDATES, 1):
session_id = f"MOCK_{uuid.uuid4().hex[:8].upper()}"
# 生成评估报告
report = f"""## 面试评估报告
**候选人**: {c['name']}
**综合评分**: {c['score']}/100
### 各维度评分
- 专业技能: {random.randint(70, 95)}
- 沟通能力: {random.randint(70, 95)}
- 服务意识: {random.randint(70, 95)}
- 学习能力: {random.randint(70, 95)}
### 风险评估
风险等级: {c['risk']}
### 总结
该候选人整体表现{"良好" if c['score'] >= 80 else "一般"},建议{"录用" if c['score'] >= 75 else "观察"}
"""
# 1. 插入评估记录
sql1 = f"""INSERT INTO ci_interview_assessments (
session_id, candidate_name, assessment_report, current_stage
) VALUES (
'{session_id}', '{c["name"]}', '{report.replace("'", "''")}', '{c["status"]}'
)"""
print(f"\n📝 插入候选人 {idx}: {c['name']}")
result = await execute_sql(sql1, "assessments")
print(f" 评估记录: code={result.get('code')}")
# 2. 插入对话记录
for j, d in enumerate(DIALOGUES, 1):
log_id = f"LOG_{session_id}_{j:02d}"
sql2 = f"""INSERT INTO ci_interview_logs (
log_id, session_id, stage, round, ai_question, user_answer, log_type
) VALUES (
'{log_id}', '{session_id}', '{d["stage"]}', '{j}',
'{d["q"].replace("'", "''")}', '{d["a"].replace("'", "''")}', 'interview'
)"""
result = await execute_sql(sql2, "logs")
print(f" 对话{j}: code={result.get('code')}")
await asyncio.sleep(0.5)
print("\n" + "=" * 50)
print("✅ 完成!")
print("=" * 50)
if __name__ == "__main__":
asyncio.run(main())

73
backend/main.py Normal file
View File

@@ -0,0 +1,73 @@
"""
AI Interview Backend - FastAPI 应用入口
"""
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from app.config import settings
from app.routers import candidate, room, chat, init, files, admin
# 创建 FastAPI 应用
app = FastAPI(
title="AI Interview API",
description="AI 语音面试系统后端 API",
version="0.1.0",
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
)
# 配置 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由(用户端)
app.include_router(candidate.router, prefix="/api", tags=["候选人"])
app.include_router(room.router, prefix="/api", tags=["房间"])
app.include_router(chat.router, prefix="/api", tags=["对话"])
app.include_router(init.router, prefix="/api", tags=["初始化"])
app.include_router(files.router, prefix="/api", tags=["文件"])
# 管理后台路由
app.include_router(admin.router, tags=["管理后台"])
@app.get("/health")
async def health_check():
"""健康检查"""
return {"status": "ok", "version": "0.1.0"}
@app.on_event("startup")
async def startup_event():
"""应用启动事件"""
logger.info("AI Interview Backend starting...")
logger.info(f"Debug mode: {settings.DEBUG}")
logger.info(f"Coze Bot ID: {settings.COZE_BOT_ID}")
logger.info(f"Tunnel URL: {settings.TUNNEL_URL or settings.NGROK_URL}")
# 创建上传目录
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
logger.info(f"Upload directory: {settings.UPLOAD_DIR}")
@app.on_event("shutdown")
async def shutdown_event():
"""应用关闭事件"""
logger.info("AI Interview Backend shutting down...")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=settings.API_PORT,
reload=settings.DEBUG,
)

26
backend/requirements.txt Normal file
View File

@@ -0,0 +1,26 @@
# FastAPI 框架
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
# HTTP 客户端
httpx>=0.26.0
# 文件上传
python-multipart>=0.0.6
# 数据验证
pydantic>=2.5.0
pydantic-settings>=2.1.0
# 环境变量
python-dotenv>=1.0.0
# CORS
starlette>=0.35.0
# 日志
loguru>=0.7.2
# PDF 生成(可选)
# weasyprint>=60.0
# reportlab>=4.0.0

167
backend/test_coze.py Normal file
View File

@@ -0,0 +1,167 @@
"""
Coze API 配置测试脚本
运行: python test_coze.py
"""
import asyncio
import httpx
# 配置
COZE_API_BASE = "https://api.coze.cn"
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
COZE_BOT_ID = "7595113005181386792"
COZE_DATABASE_ID = "7595077053909712922"
async def test_bot_info():
"""测试 Bot 信息"""
print("\n" + "=" * 50)
print("1. 测试 Bot 信息")
print("=" * 50)
url = f"{COZE_API_BASE}/v1/bot/get_online_info"
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.get(
url,
params={"bot_id": COZE_BOT_ID},
headers=headers
)
data = response.json()
print(f"Status: {response.status_code}")
print(f"Response: {data}")
if data.get("code") == 0:
bot_info = data.get("data", {})
print(f"\n✅ Bot 名称: {bot_info.get('name', 'N/A')}")
print(f"✅ Bot ID: {COZE_BOT_ID}")
return True
else:
print(f"\n❌ 错误: {data.get('msg', 'Unknown error')}")
return False
except Exception as e:
print(f"❌ 请求失败: {e}")
return False
async def test_database():
"""测试数据库查询"""
print("\n" + "=" * 50)
print("2. 测试数据库连接和字段结构")
print("=" * 50)
url = f"{COZE_API_BASE}/v1/database/query"
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json",
}
payload = {
"database_id": COZE_DATABASE_ID,
"page": 1,
"page_size": 5,
}
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(url, json=payload, headers=headers)
data = response.json()
print(f"Status: {response.status_code}")
if data.get("code") == 0:
db_data = data.get("data", {})
records = db_data.get("records", [])
total = db_data.get("total", 0)
print(f"\n✅ 数据库连接成功!")
print(f"✅ 数据库 ID: {COZE_DATABASE_ID}")
print(f"✅ 总记录数: {total}")
if records:
print(f"\n📋 数据库字段列表:")
first_record = records[0]
for key in first_record.keys():
value = first_record[key]
value_preview = str(value)[:50] + "..." if len(str(value)) > 50 else value
print(f" - {key}: {value_preview}")
else:
print("\n⚠️ 数据库为空,无法显示字段结构")
print(" 这是正常的,首次面试后会有数据")
return True
else:
print(f"\n❌ 错误: {data.get('msg', 'Unknown error')}")
print(f" 完整响应: {data}")
return False
except Exception as e:
print(f"❌ 请求失败: {e}")
return False
async def test_audio_room():
"""测试语音房间创建(不实际创建,只检查 API 可用性)"""
print("\n" + "=" * 50)
print("3. 测试语音房间 API模拟请求")
print("=" * 50)
# 我们只测试 API 是否可访问,不实际创建房间
url = f"{COZE_API_BASE}/v1/audio/rooms"
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json",
}
# 使用测试参数
payload = {
"bot_id": COZE_BOT_ID,
"user_id": "test_user_001",
}
print(f"API 端点: {url}")
print(f"请求参数: {payload}")
print("\n⚠️ 跳过实际创建房间测试(避免产生费用)")
print("✅ API 配置看起来正确")
return True
async def main():
print("\n🔍 Coze API 配置测试")
print("=" * 50)
print(f"API Base: {COZE_API_BASE}")
print(f"Bot ID: {COZE_BOT_ID}")
print(f"Database ID: {COZE_DATABASE_ID}")
print(f"Token: {COZE_PAT_TOKEN[:20]}...")
results = []
# 测试 Bot
results.append(await test_bot_info())
# 测试数据库
results.append(await test_database())
# 测试语音房间
results.append(await test_audio_room())
# 汇总
print("\n" + "=" * 50)
print("📊 测试结果汇总")
print("=" * 50)
tests = ["Bot 信息", "数据库连接", "语音房间 API"]
for i, (test_name, result) in enumerate(zip(tests, results)):
status = "✅ 通过" if result else "❌ 失败"
print(f"{i + 1}. {test_name}: {status}")
if all(results):
print("\n🎉 所有测试通过!配置正确!")
else:
print("\n⚠️ 部分测试失败,请检查配置")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,78 @@
"""
测试插入单条对话记录
"""
import asyncio
import httpx
import json
COZE_API_BASE = "https://api.coze.cn"
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
WORKFLOW_ID = "7597376294612107318"
async def execute_sql(sql: str, table: str) -> dict:
"""通过工作流执行 SQL"""
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json"
}
input_json = json.dumps({"table": table, "sql": sql}, ensure_ascii=False)
print(f"\n📤 发送请求:")
print(f" table: {table}")
print(f" sql: {sql[:100]}...")
print(f" input: {input_json[:200]}...")
payload = {
"workflow_id": WORKFLOW_ID,
"parameters": {
"input": input_json
}
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{COZE_API_BASE}/v1/workflow/run",
headers=headers,
json=payload
)
result = response.json()
print(f"\n📥 响应:")
print(f" code: {result.get('code')}")
print(f" msg: {result.get('msg')}")
if result.get('data'):
print(f" data: {result.get('data')[:200]}...")
if result.get('debug_url'):
print(f" debug: {result.get('debug_url')}")
return result
async def main():
print("=" * 60)
print("测试 INSERT 到 ci_interview_logs")
print("=" * 60)
# 测试 1: 简单的 INSERT
sql1 = """INSERT INTO ci_interview_logs (session_id, stage, round, ai_question, user_answer, log_type) VALUES ('TEST_001', '测试', '1', '测试问题', '测试回答', 'test')"""
print("\n\n🧪 测试 1: 简单 INSERT")
result = await execute_sql(sql1, "logs")
# 测试 2: 查询刚插入的数据
sql2 = """SELECT * FROM ci_interview_logs WHERE session_id = 'TEST_001' LIMIT 5"""
print("\n\n🧪 测试 2: 查询刚插入的数据")
result = await execute_sql(sql2, "logs")
# 测试 3: 删除测试数据
sql3 = """DELETE FROM ci_interview_logs WHERE session_id = 'TEST_001'"""
print("\n\n🧪 测试 3: 删除测试数据")
result = await execute_sql(sql3, "logs")
print("\n" + "=" * 60)
print("测试完成")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,38 @@
"""测试单条 INSERT"""
import asyncio
import httpx
import json
import uuid
COZE_API_BASE = "https://api.coze.cn"
COZE_PAT_TOKEN = "pat_nd1wU47WyPS9GCIyJ1clnH8h1WOQXGrYELX8w73TnSZaYbFdYD4swIhzcETBUbfT"
WORKFLOW_ID = "7597376294612107318"
async def main():
log_id = f"LOG_TEST_{uuid.uuid4().hex[:8]}"
sql = f"""INSERT INTO ci_interview_logs (log_id, session_id, stage, round, ai_question, user_answer, log_type) VALUES ('{log_id}', 'TEST_001', '测试', '1', '测试问题', '测试回答', 'test')"""
input_json = json.dumps({"table": "logs", "sql": sql}, ensure_ascii=False)
print(f"log_id: {log_id}")
print(f"sql: {sql[:80]}...")
headers = {
"Authorization": f"Bearer {COZE_PAT_TOKEN}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{COZE_API_BASE}/v1/workflow/run",
headers=headers,
json={"workflow_id": WORKFLOW_ID, "parameters": {"input": input_json}}
)
result = response.json()
print(f"code: {result.get('code')}")
print(f"msg: {result.get('msg')}")
print(f"data: {result.get('data', '')[:200]}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,89 @@
"""
测试工作流 C - 通用 SQL 查询JSON 格式输入)
"""
import asyncio
import httpx
import os
import json
from dotenv import load_dotenv
load_dotenv()
PAT_TOKEN = os.getenv("COZE_PAT_TOKEN")
WORKFLOW_ID = "7597376294612107318"
async def test_query(table: str, sql: str):
url = "https://api.coze.cn/v1/workflow/run"
headers = {
"Authorization": f"Bearer {PAT_TOKEN}",
"Content-Type": "application/json"
}
# JSON 格式输入
input_data = json.dumps({
"table": table,
"sql": sql
}, ensure_ascii=False)
payload = {
"workflow_id": WORKFLOW_ID,
"parameters": {
"input": input_data
}
}
print(f"Table: {table}")
print(f"SQL: {sql[:80]}...")
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, json=payload, headers=headers)
print(f"Status: {response.status_code}")
data = response.json()
if data.get("code") == 0:
print(f"✅ 成功!")
result_str = data.get("data", "")
if result_str:
try:
inner_data = json.loads(result_str)
if isinstance(inner_data, list):
print(f"返回 {len(inner_data)} 条记录")
if inner_data:
print(json.dumps(inner_data[0], indent=2, ensure_ascii=False))
else:
print(json.dumps(inner_data, indent=2, ensure_ascii=False))
except:
print(f"Raw: {result_str[:300]}")
else:
print(f"❌ 失败: {data.get('msg')}")
if data.get("debug_url"):
print(f"Debug: {data.get('debug_url')}")
async def main():
print("=" * 60)
print("测试 1: 查询面试评估列表 (assessments)")
print("=" * 60)
await test_query(
"assessments",
"SELECT session_id, candidate_name, bstudio_create_time FROM ci_interview_assessments ORDER BY bstudio_create_time DESC LIMIT 5"
)
print("\n" + "=" * 60)
print("测试 2: 查询对话日志 (logs)")
print("=" * 60)
await test_query(
"logs",
"SELECT log_id, session_id, stage, ai_question, user_answer FROM ci_interview_logs LIMIT 3"
)
print("\n" + "=" * 60)
print("测试 3: 查询业务配置 (config)")
print("=" * 60)
await test_query(
"config",
"SELECT config_id, config_type, item_name FROM ci_business_config LIMIT 3"
)
if __name__ == "__main__":
asyncio.run(main())

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.