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()