Initial commit: AI Interview System
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# AI Interview Backend App
|
||||
55
backend/app/config.py
Normal file
55
backend/app/config.py
Normal 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()
|
||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routers
|
||||
239
backend/app/routers/admin.py
Normal file
239
backend/app/routers/admin.py
Normal 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
|
||||
})
|
||||
83
backend/app/routers/candidate.py
Normal file
83
backend/app/routers/candidate.py
Normal 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))
|
||||
48
backend/app/routers/chat.py
Normal file
48
backend/app/routers/chat.py
Normal 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 API(Chatflow 对话)
|
||||
# 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))
|
||||
35
backend/app/routers/files.py
Normal file
35
backend/app/routers/files.py
Normal 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"
|
||||
)
|
||||
69
backend/app/routers/init.py
Normal file
69
backend/app/routers/init.py
Normal 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
129
backend/app/routers/room.py
Normal 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))
|
||||
97
backend/app/routers/upload.py
Normal file
97
backend/app/routers/upload.py
Normal 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
100
backend/app/schemas.py
Normal 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
|
||||
4
backend/app/services/__init__.py
Normal file
4
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Services
|
||||
from .coze_service import CozeService, coze_service
|
||||
|
||||
__all__ = ["CozeService", "coze_service"]
|
||||
839
backend/app/services/coze_service.py
Normal file
839
backend/app/services/coze_service.py
Normal 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 的 ID(Chatflow)
|
||||
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
40
backend/check_table.py
Normal 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
137
backend/insert_full_mock.py
Normal 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
261
backend/insert_mock_data.py
Normal 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个月重点学习产品知识和销售流程;2)3-6个月参与实战,由资深顾问带教;3)6个月后独立负责客户。预计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
123
backend/insert_mock_full.py
Normal 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())
|
||||
100
backend/insert_mock_simple.py
Normal file
100
backend/insert_mock_simple.py
Normal 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
73
backend/main.py
Normal 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
26
backend/requirements.txt
Normal 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
167
backend/test_coze.py
Normal 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())
|
||||
78
backend/test_insert_log.py
Normal file
78
backend/test_insert_log.py
Normal 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())
|
||||
38
backend/test_single_insert.py
Normal file
38
backend/test_single_insert.py
Normal 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())
|
||||
89
backend/test_workflow_c.py
Normal file
89
backend/test_workflow_c.py
Normal 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())
|
||||
BIN
backend/uploads/resume_06efd8fbdf4e.pdf
Normal file
BIN
backend/uploads/resume_06efd8fbdf4e.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_0c832769e6e1.pdf
Normal file
BIN
backend/uploads/resume_0c832769e6e1.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_12d917aff494.pdf
Normal file
BIN
backend/uploads/resume_12d917aff494.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_314534591e3a.pdf
Normal file
BIN
backend/uploads/resume_314534591e3a.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_54c858df7b5c.pdf
Normal file
BIN
backend/uploads/resume_54c858df7b5c.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_5649e33085e7.pdf
Normal file
BIN
backend/uploads/resume_5649e33085e7.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_822a417ddd93.pdf
Normal file
BIN
backend/uploads/resume_822a417ddd93.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_9776de07e4a8.pdf
Normal file
BIN
backend/uploads/resume_9776de07e4a8.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_9c8d14dffe57.pdf
Normal file
BIN
backend/uploads/resume_9c8d14dffe57.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_a1046f432e25.pdf
Normal file
BIN
backend/uploads/resume_a1046f432e25.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_c413896bb575.pdf
Normal file
BIN
backend/uploads/resume_c413896bb575.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_c66c17984409.pdf
Normal file
BIN
backend/uploads/resume_c66c17984409.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_d5099390d096.pdf
Normal file
BIN
backend/uploads/resume_d5099390d096.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_ea022e674281.pdf
Normal file
BIN
backend/uploads/resume_ea022e674281.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_eeec534465a4.pdf
Normal file
BIN
backend/uploads/resume_eeec534465a4.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/resume_fe9abe3de07a.pdf
Normal file
BIN
backend/uploads/resume_fe9abe3de07a.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user