feat: 添加双人对练功能
Some checks failed
continuous-integration/drone/push Build is failing

- 新增数据库迁移脚本 (practice_rooms, practice_room_messages)
- 新增后端 API: 房间创建/加入/消息同步/报告生成
- 新增前端页面: 入口页/对练房间/报告页
- 新增 AI 双人评估服务和提示词
This commit is contained in:
yuliang_guo
2026-01-28 15:20:03 +08:00
parent fc299ed7b7
commit b6aea2e23d
14 changed files with 4195 additions and 0 deletions

View File

@@ -29,6 +29,7 @@ from .sql_executor import router as sql_executor_router
from .exam import router as exam_router
from .practice import router as practice_router
from .practice_room import router as practice_room_router
from .course_chat import router as course_chat_router
from .broadcast import router as broadcast_router
from .preview import router as preview_router
@@ -69,6 +70,8 @@ api_router.include_router(sql_executor_router, prefix="/sql", tags=["sql-executo
api_router.include_router(exam_router, tags=["exams"])
# practice_router 陪练功能路由
api_router.include_router(practice_router, prefix="/practice", tags=["practice"])
# practice_room_router 双人对练房间路由prefix在router内部定义为/practice/rooms
api_router.include_router(practice_room_router, tags=["practice-room"])
# course_chat_router 与课程对话路由
api_router.include_router(course_chat_router, prefix="/course", tags=["course-chat"])
# broadcast_router 播课功能路由不添加prefix路径在router内部定义

View File

@@ -0,0 +1,617 @@
"""
双人对练房间 API
功能:
- 房间创建、加入、退出
- 房间状态查询
- 实时消息推送SSE
- 消息发送
"""
import asyncio
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.services.practice_room_service import PracticeRoomService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/practice/rooms", tags=["双人对练房间"])
# ==================== Schema 定义 ====================
class CreateRoomRequest(BaseModel):
"""创建房间请求"""
scene_id: Optional[int] = Field(None, description="场景ID")
scene_name: Optional[str] = Field(None, description="场景名称")
scene_type: Optional[str] = Field(None, description="场景类型")
scene_background: Optional[str] = Field(None, description="场景背景")
role_a_name: str = Field("销售顾问", description="角色A名称")
role_b_name: str = Field("顾客", description="角色B名称")
role_a_description: Optional[str] = Field(None, description="角色A描述")
role_b_description: Optional[str] = Field(None, description="角色B描述")
host_role: str = Field("A", description="房主选择的角色(A/B)")
room_name: Optional[str] = Field(None, description="房间名称")
class JoinRoomRequest(BaseModel):
"""加入房间请求"""
room_code: str = Field(..., description="房间码")
class SendMessageRequest(BaseModel):
"""发送消息请求"""
content: str = Field(..., description="消息内容")
class RoomResponse(BaseModel):
"""房间响应"""
id: int
room_code: str
room_name: Optional[str]
scene_id: Optional[int]
scene_name: Optional[str]
scene_type: Optional[str]
role_a_name: str
role_b_name: str
host_user_id: int
guest_user_id: Optional[int]
host_role: str
status: str
created_at: datetime
started_at: Optional[datetime]
ended_at: Optional[datetime]
duration_seconds: int
total_turns: int
class Config:
from_attributes = True
class RoomDetailResponse(BaseModel):
"""房间详情响应(包含用户信息)"""
room: RoomResponse
host_user: Optional[dict]
guest_user: Optional[dict]
host_role_name: Optional[str]
guest_role_name: Optional[str]
my_role: Optional[str]
my_role_name: Optional[str]
class MessageResponse(BaseModel):
"""消息响应"""
id: int
room_id: int
user_id: Optional[int]
message_type: str
content: Optional[str]
role_name: Optional[str]
sequence: int
created_at: datetime
class Config:
from_attributes = True
# ==================== API 端点 ====================
@router.post("", summary="创建房间")
async def create_room(
request: CreateRoomRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
创建双人对练房间
- 返回房间码,可分享给对方加入
- 房主可选择扮演角色A或B
"""
service = PracticeRoomService(db)
try:
room = await service.create_room(
host_user_id=current_user.id,
scene_id=request.scene_id,
scene_name=request.scene_name,
scene_type=request.scene_type,
scene_background=request.scene_background,
role_a_name=request.role_a_name,
role_b_name=request.role_b_name,
role_a_description=request.role_a_description,
role_b_description=request.role_b_description,
host_role=request.host_role,
room_name=request.room_name
)
return {
"code": 200,
"message": "房间创建成功",
"data": {
"room_code": room.room_code,
"room_id": room.id,
"room_name": room.room_name,
"my_role": room.host_role,
"my_role_name": room.get_role_name(room.host_role)
}
}
except Exception as e:
logger.error(f"创建房间失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/join", summary="加入房间")
async def join_room(
request: JoinRoomRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
通过房间码加入房间
- 房间最多容纳2人
- 加入后自动分配对方角色
"""
service = PracticeRoomService(db)
try:
room = await service.join_room(
room_code=request.room_code.upper(),
user_id=current_user.id
)
my_role = room.get_user_role(current_user.id)
return {
"code": 200,
"message": "加入房间成功",
"data": {
"room_code": room.room_code,
"room_id": room.id,
"room_name": room.room_name,
"status": room.status,
"my_role": my_role,
"my_role_name": room.get_role_name(my_role)
}
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"加入房间失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{room_code}", summary="获取房间详情")
async def get_room(
room_code: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取房间详情,包含参与者信息
"""
service = PracticeRoomService(db)
room_data = await service.get_room_with_users(room_code.upper())
if not room_data:
raise HTTPException(status_code=404, detail="房间不存在")
room = room_data["room"]
host_user = room_data["host_user"]
guest_user = room_data["guest_user"]
my_role = room.get_user_role(current_user.id)
return {
"code": 200,
"message": "success",
"data": {
"room": {
"id": room.id,
"room_code": room.room_code,
"room_name": room.room_name,
"scene_id": room.scene_id,
"scene_name": room.scene_name,
"scene_type": room.scene_type,
"scene_background": room.scene_background,
"role_a_name": room.role_a_name,
"role_b_name": room.role_b_name,
"role_a_description": room.role_a_description,
"role_b_description": room.role_b_description,
"host_role": room.host_role,
"status": room.status,
"created_at": room.created_at.isoformat() if room.created_at else None,
"started_at": room.started_at.isoformat() if room.started_at else None,
"ended_at": room.ended_at.isoformat() if room.ended_at else None,
"duration_seconds": room.duration_seconds,
"total_turns": room.total_turns,
"role_a_turns": room.role_a_turns,
"role_b_turns": room.role_b_turns
},
"host_user": {
"id": host_user.id,
"username": host_user.username,
"full_name": host_user.full_name,
"avatar_url": host_user.avatar_url
} if host_user else None,
"guest_user": {
"id": guest_user.id,
"username": guest_user.username,
"full_name": guest_user.full_name,
"avatar_url": guest_user.avatar_url
} if guest_user else None,
"host_role_name": room_data["host_role_name"],
"guest_role_name": room_data["guest_role_name"],
"my_role": my_role,
"my_role_name": room.get_role_name(my_role) if my_role else None,
"is_host": current_user.id == room.host_user_id
}
}
@router.post("/{room_code}/start", summary="开始对练")
async def start_practice(
room_code: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
开始对练(仅房主可操作)
- 需要房间状态为 ready双方都已加入
"""
service = PracticeRoomService(db)
try:
room = await service.start_practice(
room_code=room_code.upper(),
user_id=current_user.id
)
return {
"code": 200,
"message": "对练已开始",
"data": {
"room_code": room.room_code,
"status": room.status,
"started_at": room.started_at.isoformat() if room.started_at else None
}
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"开始对练失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{room_code}/end", summary="结束对练")
async def end_practice(
room_code: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
结束对练
- 任意参与者都可结束
- 结束后可查看分析报告
"""
service = PracticeRoomService(db)
try:
room = await service.end_practice(
room_code=room_code.upper(),
user_id=current_user.id
)
return {
"code": 200,
"message": "对练已结束",
"data": {
"room_code": room.room_code,
"room_id": room.id,
"status": room.status,
"duration_seconds": room.duration_seconds,
"total_turns": room.total_turns
}
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"结束对练失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{room_code}/leave", summary="离开房间")
async def leave_room(
room_code: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
离开房间
- 房主离开则关闭房间
- 嘉宾离开则房间回到等待状态
"""
service = PracticeRoomService(db)
success = await service.leave_room(
room_code=room_code.upper(),
user_id=current_user.id
)
if not success:
raise HTTPException(status_code=400, detail="离开房间失败")
return {
"code": 200,
"message": "已离开房间"
}
@router.post("/{room_code}/message", summary="发送消息")
async def send_message(
room_code: str,
request: SendMessageRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
发送聊天消息
- 仅对练中状态可发送
"""
service = PracticeRoomService(db)
# 获取房间
room = await service.get_room_by_code(room_code.upper())
if not room:
raise HTTPException(status_code=404, detail="房间不存在")
if room.status != "practicing":
raise HTTPException(status_code=400, detail="对练未在进行中")
# 获取用户角色
role_name = room.get_user_role_name(current_user.id)
if not role_name:
raise HTTPException(status_code=403, detail="您不是房间参与者")
# 发送消息
message = await service.send_message(
room_id=room.id,
user_id=current_user.id,
content=request.content,
role_name=role_name
)
return {
"code": 200,
"message": "发送成功",
"data": message.to_dict()
}
@router.get("/{room_code}/messages", summary="获取消息列表")
async def get_messages(
room_code: str,
since_sequence: int = Query(0, description="从该序号之后开始获取"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取房间消息列表
- 用于轮询获取新消息
- 传入 since_sequence 只获取新消息
"""
service = PracticeRoomService(db)
room = await service.get_room_by_code(room_code.upper())
if not room:
raise HTTPException(status_code=404, detail="房间不存在")
messages = await service.get_messages(
room_id=room.id,
since_sequence=since_sequence
)
return {
"code": 200,
"message": "success",
"data": {
"messages": [msg.to_dict() for msg in messages],
"room_status": room.status,
"last_sequence": messages[-1].sequence if messages else since_sequence
}
}
@router.get("/{room_code}/stream", summary="消息流SSE")
async def message_stream(
room_code: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
实时消息流Server-Sent Events
- 用于实时接收房间消息
- 前端使用 EventSource 连接
"""
service = PracticeRoomService(db)
room = await service.get_room_by_code(room_code.upper())
if not room:
raise HTTPException(status_code=404, detail="房间不存在")
async def event_generator():
last_sequence = 0
while True:
# 获取新消息
messages = await service.get_messages(
room_id=room.id,
since_sequence=last_sequence
)
for msg in messages:
yield f"event: message\ndata: {msg.to_dict()}\n\n"
last_sequence = msg.sequence
# 检查房间状态
room_status = await service.get_room_by_id(room.id)
if room_status and room_status.status in ["completed", "canceled"]:
yield f"event: room_closed\ndata: {{\"status\": \"{room_status.status}\"}}\n\n"
break
# 等待一段时间再轮询
await asyncio.sleep(0.5)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
@router.get("/{room_code}/report", summary="获取或生成对练报告")
async def get_practice_report(
room_code: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取双人对练报告
- 如果报告不存在,则调用 AI 生成
- 返回双方表现评估
"""
from app.services.ai.duo_practice_analysis_service import DuoPracticeAnalysisService
service = PracticeRoomService(db)
# 获取房间详情
room_data = await service.get_room_with_users(room_code.upper())
if not room_data:
raise HTTPException(status_code=404, detail="房间不存在")
room = room_data["room"]
# 检查房间是否已完成
if room.status != "completed":
raise HTTPException(status_code=400, detail="对练尚未结束,无法生成报告")
# 获取所有消息
messages = await service.get_all_messages(room.id)
chat_messages = [m for m in messages if m.message_type == "chat"]
if not chat_messages:
raise HTTPException(status_code=400, detail="暂无对话记录")
# 准备对话历史
dialogue_history = [
{
"sequence": m.sequence,
"role_name": m.role_name,
"content": m.content,
"user_id": m.user_id
}
for m in chat_messages
]
# 获取用户名称
host_name = room_data["host_user"].full_name if room_data["host_user"] else "用户A"
guest_name = room_data["guest_user"].full_name if room_data["guest_user"] else "用户B"
# 确定角色对应的用户名
if room.host_role == "A":
user_a_name = host_name
user_b_name = guest_name
else:
user_a_name = guest_name
user_b_name = host_name
# 调用 AI 分析
analysis_service = DuoPracticeAnalysisService()
result = await analysis_service.analyze(
scene_name=room.scene_name or "双人对练",
scene_background=room.scene_background or "",
role_a_name=room.role_a_name,
role_b_name=room.role_b_name,
role_a_description=room.role_a_description or f"扮演{room.role_a_name}",
role_b_description=room.role_b_description or f"扮演{room.role_b_name}",
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_history,
duration_seconds=room.duration_seconds,
total_turns=room.total_turns,
db=db
)
return {
"code": 200,
"message": "success",
"data": {
"room": {
"id": room.id,
"room_code": room.room_code,
"room_name": room.room_name,
"scene_name": room.scene_name,
"duration_seconds": room.duration_seconds,
"total_turns": room.total_turns
},
"analysis": analysis_service.result_to_dict(result)
}
}
@router.get("", summary="获取我的房间列表")
async def get_my_rooms(
status: Optional[str] = Query(None, description="按状态筛选"),
limit: int = Query(20, description="数量限制"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户的房间列表
"""
service = PracticeRoomService(db)
rooms = await service.get_user_rooms(
user_id=current_user.id,
status=status,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": {
"rooms": [
{
"id": room.id,
"room_code": room.room_code,
"room_name": room.room_name,
"scene_name": room.scene_name,
"status": room.status,
"is_host": room.host_user_id == current_user.id,
"created_at": room.created_at.isoformat() if room.created_at else None,
"duration_seconds": room.duration_seconds,
"total_turns": room.total_turns
}
for room in rooms
]
}
}

View File

@@ -0,0 +1,122 @@
"""
双人对练房间模型
功能:
- 房间管理(创建、加入、状态)
- 参与者管理
- 实时消息同步
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.models.base import Base
class PracticeRoom(Base):
"""双人对练房间模型"""
__tablename__ = "practice_rooms"
id = Column(Integer, primary_key=True, index=True, comment="房间ID")
room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码")
room_name = Column(String(200), comment="房间名称")
# 场景信息
scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID")
scene_name = Column(String(200), comment="场景名称")
scene_type = Column(String(50), comment="场景类型")
scene_background = Column(Text, comment="场景背景")
# 角色设置
role_a_name = Column(String(50), default="角色A", comment="角色A名称如销售顾问")
role_b_name = Column(String(50), default="角色B", comment="角色B名称如顾客")
role_a_description = Column(Text, comment="角色A描述")
role_b_description = Column(Text, comment="角色B描述")
# 参与者信息
host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID")
guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID")
host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)")
max_participants = Column(Integer, default=2, comment="最大参与人数")
# 状态和时间
status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
started_at = Column(DateTime, comment="开始时间")
ended_at = Column(DateTime, comment="结束时间")
duration_seconds = Column(Integer, default=0, comment="对练时长(秒)")
# 对话统计
total_turns = Column(Integer, default=0, comment="总对话轮次")
role_a_turns = Column(Integer, default=0, comment="角色A发言次数")
role_b_turns = Column(Integer, default=0, comment="角色B发言次数")
# 软删除
is_deleted = Column(Boolean, default=False, comment="是否删除")
deleted_at = Column(DateTime, comment="删除时间")
def __repr__(self):
return f"<PracticeRoom(code='{self.room_code}', status='{self.status}')>"
@property
def is_full(self) -> bool:
"""房间是否已满"""
return self.guest_user_id is not None
@property
def participant_count(self) -> int:
"""当前参与人数"""
count = 1 # 房主
if self.guest_user_id:
count += 1
return count
def get_user_role(self, user_id: int) -> str:
"""获取用户在房间中的角色"""
if user_id == self.host_user_id:
return self.host_role
elif user_id == self.guest_user_id:
return "B" if self.host_role == "A" else "A"
return None
def get_role_name(self, role: str) -> str:
"""获取角色名称"""
if role == "A":
return self.role_a_name
elif role == "B":
return self.role_b_name
return None
def get_user_role_name(self, user_id: int) -> str:
"""获取用户的角色名称"""
role = self.get_user_role(user_id)
return self.get_role_name(role) if role else None
class PracticeRoomMessage(Base):
"""房间实时消息模型"""
__tablename__ = "practice_room_messages"
id = Column(Integer, primary_key=True, index=True, comment="消息ID")
room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID")
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID")
message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end")
content = Column(Text, comment="消息内容")
role_name = Column(String(50), comment="角色名称")
sequence = Column(Integer, nullable=False, comment="消息序号")
created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间")
def __repr__(self):
return f"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>"
def to_dict(self) -> dict:
"""转换为字典用于SSE推送"""
return {
"id": self.id,
"room_id": self.room_id,
"user_id": self.user_id,
"message_type": self.message_type,
"content": self.content,
"role_name": self.role_name,
"sequence": self.sequence,
"created_at": self.created_at.isoformat() if self.created_at else None
}

View File

@@ -0,0 +1,323 @@
"""
双人对练分析服务
功能:
- 分析双人对练对话
- 生成双方评估报告
- 对话标注和建议
"""
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from app.services.ai.ai_service import AIService
from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT
logger = logging.getLogger(__name__)
@dataclass
class UserEvaluation:
"""用户评估结果"""
user_name: str
role_name: str
total_score: int
dimensions: Dict[str, Dict[str, Any]]
highlights: List[str]
improvements: List[Dict[str, str]]
@dataclass
class DuoPracticeAnalysisResult:
"""双人对练分析结果"""
# 整体评估
interaction_quality: int = 0
scene_restoration: int = 0
overall_comment: str = ""
# 用户A评估
user_a_evaluation: Optional[UserEvaluation] = None
# 用户B评估
user_b_evaluation: Optional[UserEvaluation] = None
# 对话标注
dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list)
# AI 元数据
raw_response: str = ""
ai_provider: str = ""
ai_model: str = ""
ai_latency_ms: int = 0
class DuoPracticeAnalysisService:
"""
双人对练分析服务
使用示例:
```python
service = DuoPracticeAnalysisService()
result = await service.analyze(
scene_name="销售场景",
scene_background="客户咨询产品",
role_a_name="销售顾问",
role_b_name="顾客",
user_a_name="张三",
user_b_name="李四",
dialogue_history=dialogue_list,
duration_seconds=300,
total_turns=20
)
```
"""
MODULE_CODE = "duo_practice_analysis"
async def analyze(
self,
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""
分析双人对练
Args:
scene_name: 场景名称
scene_background: 场景背景
role_a_name: 角色A名称
role_b_name: 角色B名称
role_a_description: 角色A描述
role_b_description: 角色B描述
user_a_name: 用户A名称
user_b_name: 用户B名称
dialogue_history: 对话历史列表
duration_seconds: 对练时长(秒)
total_turns: 总对话轮次
db: 数据库会话
Returns:
DuoPracticeAnalysisResult: 分析结果
"""
try:
logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}")
# 格式化对话历史
dialogue_text = self._format_dialogue_history(dialogue_history)
# 创建 AI 服务
ai_service = AIService(module_code=self.MODULE_CODE, db_session=db)
# 构建用户提示词
user_prompt = USER_PROMPT.format(
scene_name=scene_name,
scene_background=scene_background or "未设置",
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description or f"扮演{role_a_name}角色",
role_b_description=role_b_description or f"扮演{role_b_name}角色",
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_text,
duration_seconds=duration_seconds,
total_turns=total_turns
)
# 调用 AI
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
]
ai_response = await ai_service.chat(
messages=messages,
model="gemini-3-flash-preview", # 使用快速模型
temperature=0.3,
prompt_name="duo_practice_analysis"
)
logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms")
# 解析 AI 输出
result = self._parse_analysis_result(
ai_response.content,
user_a_name=user_a_name,
user_b_name=user_b_name,
role_a_name=role_a_name,
role_b_name=role_b_name
)
# 填充 AI 元数据
result.raw_response = ai_response.content
result.ai_provider = ai_response.provider
result.ai_model = ai_response.model
result.ai_latency_ms = ai_response.latency_ms
return result
except Exception as e:
logger.error(f"双人对练分析失败: {e}", exc_info=True)
# 返回空结果
return DuoPracticeAnalysisResult(
overall_comment=f"分析失败: {str(e)}"
)
def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str:
"""格式化对话历史"""
lines = []
for d in dialogues:
speaker = d.get("role_name") or d.get("speaker", "未知")
content = d.get("content", "")
seq = d.get("sequence", 0)
lines.append(f"[{seq}] {speaker}{content}")
return "\n".join(lines)
def _parse_analysis_result(
self,
ai_output: str,
user_a_name: str,
user_b_name: str,
role_a_name: str,
role_b_name: str
) -> DuoPracticeAnalysisResult:
"""解析 AI 输出"""
result = DuoPracticeAnalysisResult()
try:
# 尝试提取 JSON
json_str = ai_output
# 如果输出包含 markdown 代码块,提取其中的 JSON
if "```json" in ai_output:
start = ai_output.find("```json") + 7
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
elif "```" in ai_output:
start = ai_output.find("```") + 3
end = ai_output.find("```", start)
json_str = ai_output[start:end].strip()
data = json.loads(json_str)
# 解析整体评估
overall = data.get("overall_evaluation", {})
result.interaction_quality = overall.get("interaction_quality", 0)
result.scene_restoration = overall.get("scene_restoration", 0)
result.overall_comment = overall.get("overall_comment", "")
# 解析用户A评估
user_a_data = data.get("user_a_evaluation", {})
if user_a_data:
result.user_a_evaluation = UserEvaluation(
user_name=user_a_data.get("user_name", user_a_name),
role_name=user_a_data.get("role_name", role_a_name),
total_score=user_a_data.get("total_score", 0),
dimensions=user_a_data.get("dimensions", {}),
highlights=user_a_data.get("highlights", []),
improvements=user_a_data.get("improvements", [])
)
# 解析用户B评估
user_b_data = data.get("user_b_evaluation", {})
if user_b_data:
result.user_b_evaluation = UserEvaluation(
user_name=user_b_data.get("user_name", user_b_name),
role_name=user_b_data.get("role_name", role_b_name),
total_score=user_b_data.get("total_score", 0),
dimensions=user_b_data.get("dimensions", {}),
highlights=user_b_data.get("highlights", []),
improvements=user_b_data.get("improvements", [])
)
# 解析对话标注
result.dialogue_annotations = data.get("dialogue_annotations", [])
except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失败: {e}")
result.overall_comment = "AI 输出格式异常,请重试"
except Exception as e:
logger.error(f"解析分析结果失败: {e}")
result.overall_comment = f"解析失败: {str(e)}"
return result
def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]:
"""将结果转换为字典(用于 API 响应)"""
return {
"overall_evaluation": {
"interaction_quality": result.interaction_quality,
"scene_restoration": result.scene_restoration,
"overall_comment": result.overall_comment
},
"user_a_evaluation": {
"user_name": result.user_a_evaluation.user_name,
"role_name": result.user_a_evaluation.role_name,
"total_score": result.user_a_evaluation.total_score,
"dimensions": result.user_a_evaluation.dimensions,
"highlights": result.user_a_evaluation.highlights,
"improvements": result.user_a_evaluation.improvements
} if result.user_a_evaluation else None,
"user_b_evaluation": {
"user_name": result.user_b_evaluation.user_name,
"role_name": result.user_b_evaluation.role_name,
"total_score": result.user_b_evaluation.total_score,
"dimensions": result.user_b_evaluation.dimensions,
"highlights": result.user_b_evaluation.highlights,
"improvements": result.user_b_evaluation.improvements
} if result.user_b_evaluation else None,
"dialogue_annotations": result.dialogue_annotations,
"ai_metadata": {
"provider": result.ai_provider,
"model": result.ai_model,
"latency_ms": result.ai_latency_ms
}
}
# ==================== 全局实例 ====================
duo_practice_analysis_service = DuoPracticeAnalysisService()
# ==================== 便捷函数 ====================
async def analyze_duo_practice(
scene_name: str,
scene_background: str,
role_a_name: str,
role_b_name: str,
role_a_description: str,
role_b_description: str,
user_a_name: str,
user_b_name: str,
dialogue_history: List[Dict[str, Any]],
duration_seconds: int,
total_turns: int,
db: Any = None
) -> DuoPracticeAnalysisResult:
"""便捷函数:分析双人对练"""
return await duo_practice_analysis_service.analyze(
scene_name=scene_name,
scene_background=scene_background,
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description,
role_b_description=role_b_description,
user_a_name=user_a_name,
user_b_name=user_b_name,
dialogue_history=dialogue_history,
duration_seconds=duration_seconds,
total_turns=total_turns,
db=db
)

View File

@@ -0,0 +1,207 @@
"""
双人对练评估提示词模板
功能:评估双人角色扮演对练的表现
"""
# ==================== 元数据 ====================
PROMPT_META = {
"name": "duo_practice_analysis",
"display_name": "双人对练评估",
"description": "评估双人角色扮演对练中双方的表现",
"module": "kaopeilian",
"variables": [
"scene_name", "scene_background",
"role_a_name", "role_b_name",
"role_a_description", "role_b_description",
"user_a_name", "user_b_name",
"dialogue_history",
"duration_seconds", "total_turns"
],
"version": "1.0.0",
"author": "kaopeilian-team",
}
# ==================== 系统提示词 ====================
SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。
你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。
评估原则:
1. 客观公正,基于对话内容给出评价
2. 突出亮点,指出不足
3. 给出具体、可操作的改进建议
4. 考虑角色特点,评估角色代入度
输出格式要求:
- 必须返回有效的 JSON 格式
- 分数范围 0-100
- 建议具体可行"""
# ==================== 用户提示词模板 ====================
USER_PROMPT = """# 双人对练评估任务
## 场景信息
- **场景名称**{scene_name}
- **场景背景**{scene_background}
## 角色设置
### {role_a_name}
- **扮演者**{user_a_name}
- **角色描述**{role_a_description}
### {role_b_name}
- **扮演者**{user_b_name}
- **角色描述**{role_b_description}
## 对练数据
- **对练时长**{duration_seconds}
- **总对话轮次**{total_turns}
## 对话记录
{dialogue_history}
---
## 评估要求
请按以下 JSON 格式输出评估结果:
```json
{{
"overall_evaluation": {{
"interaction_quality": 85,
"scene_restoration": 80,
"overall_comment": "整体评价..."
}},
"user_a_evaluation": {{
"user_name": "{user_a_name}",
"role_name": "{role_a_name}",
"total_score": 85,
"dimensions": {{
"role_immersion": {{
"score": 85,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 80,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 75,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 82,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 78,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"user_b_evaluation": {{
"user_name": "{user_b_name}",
"role_name": "{role_b_name}",
"total_score": 82,
"dimensions": {{
"role_immersion": {{
"score": 80,
"comment": "角色代入度评价..."
}},
"communication": {{
"score": 85,
"comment": "沟通表达能力评价..."
}},
"professional_knowledge": {{
"score": 78,
"comment": "专业知识运用评价..."
}},
"response_quality": {{
"score": 80,
"comment": "回应质量评价..."
}},
"goal_achievement": {{
"score": 75,
"comment": "目标达成度评价..."
}}
}},
"highlights": [
"亮点1...",
"亮点2..."
],
"improvements": [
{{
"issue": "问题描述",
"suggestion": "改进建议",
"example": "示例话术"
}}
]
}},
"dialogue_annotations": [
{{
"sequence": 1,
"speaker": "{role_a_name}",
"tags": ["good_opening"],
"comment": "开场白自然得体"
}},
{{
"sequence": 3,
"speaker": "{role_b_name}",
"tags": ["needs_improvement"],
"comment": "可以更主动表达需求"
}}
]
}}
```
请基于对话内容,给出客观、专业的评估。"""
# ==================== 维度说明 ====================
DIMENSION_DESCRIPTIONS = {
"role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定",
"communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体",
"professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备",
"response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性",
"goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果"
}
# ==================== 对话标签 ====================
DIALOGUE_TAGS = {
# 正面标签
"good_opening": "开场良好",
"active_listening": "积极倾听",
"empathy": "共情表达",
"professional": "专业表现",
"good_closing": "结束得体",
"creative_response": "创意回应",
"problem_solving": "问题解决",
# 需改进标签
"needs_improvement": "需要改进",
"off_topic": "偏离主题",
"too_passive": "过于被动",
"lack_detail": "缺乏细节",
"missed_opportunity": "错失机会",
"unclear_expression": "表达不清"
}

View File

@@ -0,0 +1,514 @@
"""
双人对练房间服务
功能:
- 房间创建、加入、退出
- 房间状态管理
- 消息广播
- 对练结束处理
"""
import logging
import random
import string
from datetime import datetime
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, and_
from sqlalchemy.orm import selectinload
from app.models.practice_room import PracticeRoom, PracticeRoomMessage
from app.models.practice import PracticeDialogue, PracticeSession
from app.models.user import User
logger = logging.getLogger(__name__)
class PracticeRoomService:
"""双人对练房间服务"""
# 房间状态常量
STATUS_WAITING = "waiting" # 等待加入
STATUS_READY = "ready" # 准备就绪
STATUS_PRACTICING = "practicing" # 对练中
STATUS_COMPLETED = "completed" # 已完成
STATUS_CANCELED = "canceled" # 已取消
# 消息类型常量
MSG_TYPE_CHAT = "chat" # 聊天消息
MSG_TYPE_SYSTEM = "system" # 系统消息
MSG_TYPE_JOIN = "join" # 加入消息
MSG_TYPE_LEAVE = "leave" # 离开消息
MSG_TYPE_START = "start" # 开始消息
MSG_TYPE_END = "end" # 结束消息
def __init__(self, db: AsyncSession):
self.db = db
# ==================== 房间管理 ====================
async def create_room(
self,
host_user_id: int,
scene_id: Optional[int] = None,
scene_name: Optional[str] = None,
scene_type: Optional[str] = None,
scene_background: Optional[str] = None,
role_a_name: str = "销售顾问",
role_b_name: str = "顾客",
role_a_description: Optional[str] = None,
role_b_description: Optional[str] = None,
host_role: str = "A",
room_name: Optional[str] = None
) -> PracticeRoom:
"""
创建对练房间
Args:
host_user_id: 房主用户ID
scene_id: 场景ID可选
scene_name: 场景名称
scene_type: 场景类型
scene_background: 场景背景
role_a_name: 角色A名称
role_b_name: 角色B名称
role_a_description: 角色A描述
role_b_description: 角色B描述
host_role: 房主选择的角色A或B
room_name: 房间名称
Returns:
PracticeRoom: 创建的房间对象
"""
# 生成唯一的6位房间码
room_code = await self._generate_unique_room_code()
# 创建房间
room = PracticeRoom(
room_code=room_code,
room_name=room_name or f"{scene_name or '双人对练'}房间",
scene_id=scene_id,
scene_name=scene_name,
scene_type=scene_type,
scene_background=scene_background,
role_a_name=role_a_name,
role_b_name=role_b_name,
role_a_description=role_a_description,
role_b_description=role_b_description,
host_user_id=host_user_id,
host_role=host_role,
status=self.STATUS_WAITING
)
self.db.add(room)
await self.db.commit()
await self.db.refresh(room)
logger.info(f"创建房间成功: room_code={room_code}, host_user_id={host_user_id}")
return room
async def join_room(
self,
room_code: str,
user_id: int
) -> PracticeRoom:
"""
加入房间
Args:
room_code: 房间码
user_id: 用户ID
Returns:
PracticeRoom: 房间对象
Raises:
ValueError: 房间不存在、已满或状态不允许加入
"""
# 查询房间
room = await self.get_room_by_code(room_code)
if not room:
raise ValueError("房间不存在或已过期")
# 检查是否是房主(房主重新进入)
if room.host_user_id == user_id:
return room
# 检查房间状态
if room.status not in [self.STATUS_WAITING, self.STATUS_READY]:
raise ValueError("房间已开始对练或已结束,无法加入")
# 检查是否已满
if room.guest_user_id and room.guest_user_id != user_id:
raise ValueError("房间已满")
# 加入房间
room.guest_user_id = user_id
room.status = self.STATUS_READY
await self.db.commit()
await self.db.refresh(room)
# 发送系统消息
await self._add_system_message(room.id, f"用户已加入房间", self.MSG_TYPE_JOIN, user_id)
logger.info(f"用户加入房间: room_code={room_code}, user_id={user_id}")
return room
async def leave_room(
self,
room_code: str,
user_id: int
) -> bool:
"""
离开房间
Args:
room_code: 房间码
user_id: 用户ID
Returns:
bool: 是否成功离开
"""
room = await self.get_room_by_code(room_code)
if not room:
return False
# 如果是房主离开,取消房间
if room.host_user_id == user_id:
room.status = self.STATUS_CANCELED
await self._add_system_message(room.id, "房主离开,房间已关闭", self.MSG_TYPE_LEAVE, user_id)
# 如果是嘉宾离开
elif room.guest_user_id == user_id:
room.guest_user_id = None
room.status = self.STATUS_WAITING
await self._add_system_message(room.id, "对方已离开房间", self.MSG_TYPE_LEAVE, user_id)
else:
return False
await self.db.commit()
logger.info(f"用户离开房间: room_code={room_code}, user_id={user_id}")
return True
async def start_practice(
self,
room_code: str,
user_id: int
) -> PracticeRoom:
"""
开始对练(仅房主可操作)
Args:
room_code: 房间码
user_id: 用户ID必须是房主
Returns:
PracticeRoom: 房间对象
"""
room = await self.get_room_by_code(room_code)
if not room:
raise ValueError("房间不存在")
if room.host_user_id != user_id:
raise ValueError("只有房主可以开始对练")
if room.status != self.STATUS_READY:
raise ValueError("房间未就绪,请等待对方加入")
room.status = self.STATUS_PRACTICING
room.started_at = datetime.now()
await self.db.commit()
await self.db.refresh(room)
# 发送开始消息
await self._add_system_message(room.id, "对练开始!", self.MSG_TYPE_START)
logger.info(f"对练开始: room_code={room_code}")
return room
async def end_practice(
self,
room_code: str,
user_id: int
) -> PracticeRoom:
"""
结束对练
Args:
room_code: 房间码
user_id: 用户ID
Returns:
PracticeRoom: 房间对象
"""
room = await self.get_room_by_code(room_code)
if not room:
raise ValueError("房间不存在")
if room.status != self.STATUS_PRACTICING:
raise ValueError("对练未在进行中")
# 计算时长
if room.started_at:
duration = (datetime.now() - room.started_at).total_seconds()
room.duration_seconds = int(duration)
room.status = self.STATUS_COMPLETED
room.ended_at = datetime.now()
await self.db.commit()
await self.db.refresh(room)
# 发送结束消息
await self._add_system_message(room.id, "对练结束!", self.MSG_TYPE_END)
logger.info(f"对练结束: room_code={room_code}, duration={room.duration_seconds}s")
return room
# ==================== 消息管理 ====================
async def send_message(
self,
room_id: int,
user_id: int,
content: str,
role_name: Optional[str] = None
) -> PracticeRoomMessage:
"""
发送聊天消息
Args:
room_id: 房间ID
user_id: 发送者ID
content: 消息内容
role_name: 角色名称
Returns:
PracticeRoomMessage: 消息对象
"""
# 获取当前消息序号
sequence = await self._get_next_sequence(room_id)
message = PracticeRoomMessage(
room_id=room_id,
user_id=user_id,
message_type=self.MSG_TYPE_CHAT,
content=content,
role_name=role_name,
sequence=sequence
)
self.db.add(message)
# 更新房间统计
room = await self.get_room_by_id(room_id)
if room:
room.total_turns += 1
user_role = room.get_user_role(user_id)
if user_role == "A":
room.role_a_turns += 1
elif user_role == "B":
room.role_b_turns += 1
await self.db.commit()
await self.db.refresh(message)
return message
async def get_messages(
self,
room_id: int,
since_sequence: int = 0,
limit: int = 100
) -> List[PracticeRoomMessage]:
"""
获取房间消息用于SSE轮询
Args:
room_id: 房间ID
since_sequence: 从该序号之后开始获取
limit: 最大数量
Returns:
List[PracticeRoomMessage]: 消息列表
"""
result = await self.db.execute(
select(PracticeRoomMessage)
.where(
and_(
PracticeRoomMessage.room_id == room_id,
PracticeRoomMessage.sequence > since_sequence
)
)
.order_by(PracticeRoomMessage.sequence)
.limit(limit)
)
return list(result.scalars().all())
async def get_all_messages(self, room_id: int) -> List[PracticeRoomMessage]:
"""
获取房间所有消息
Args:
room_id: 房间ID
Returns:
List[PracticeRoomMessage]: 消息列表
"""
result = await self.db.execute(
select(PracticeRoomMessage)
.where(PracticeRoomMessage.room_id == room_id)
.order_by(PracticeRoomMessage.sequence)
)
return list(result.scalars().all())
# ==================== 查询方法 ====================
async def get_room_by_code(self, room_code: str) -> Optional[PracticeRoom]:
"""根据房间码获取房间"""
result = await self.db.execute(
select(PracticeRoom).where(
and_(
PracticeRoom.room_code == room_code,
PracticeRoom.is_deleted == False
)
)
)
return result.scalar_one_or_none()
async def get_room_by_id(self, room_id: int) -> Optional[PracticeRoom]:
"""根据ID获取房间"""
result = await self.db.execute(
select(PracticeRoom).where(
and_(
PracticeRoom.id == room_id,
PracticeRoom.is_deleted == False
)
)
)
return result.scalar_one_or_none()
async def get_user_rooms(
self,
user_id: int,
status: Optional[str] = None,
limit: int = 20
) -> List[PracticeRoom]:
"""获取用户的房间列表"""
query = select(PracticeRoom).where(
and_(
(PracticeRoom.host_user_id == user_id) | (PracticeRoom.guest_user_id == user_id),
PracticeRoom.is_deleted == False
)
)
if status:
query = query.where(PracticeRoom.status == status)
query = query.order_by(PracticeRoom.created_at.desc()).limit(limit)
result = await self.db.execute(query)
return list(result.scalars().all())
async def get_room_with_users(self, room_code: str) -> Optional[Dict[str, Any]]:
"""获取房间详情(包含用户信息)"""
room = await self.get_room_by_code(room_code)
if not room:
return None
# 获取用户信息
host_user = None
guest_user = None
if room.host_user_id:
result = await self.db.execute(
select(User).where(User.id == room.host_user_id)
)
host_user = result.scalar_one_or_none()
if room.guest_user_id:
result = await self.db.execute(
select(User).where(User.id == room.guest_user_id)
)
guest_user = result.scalar_one_or_none()
return {
"room": room,
"host_user": host_user,
"guest_user": guest_user,
"host_role_name": room.get_role_name(room.host_role),
"guest_role_name": room.get_role_name("B" if room.host_role == "A" else "A") if guest_user else None
}
# ==================== 辅助方法 ====================
async def _generate_unique_room_code(self) -> str:
"""生成唯一的6位房间码"""
for _ in range(10): # 最多尝试10次
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
# 排除容易混淆的字符
code = code.replace('0', 'X').replace('O', 'Y').replace('I', 'Z').replace('1', 'W')
# 检查是否已存在
existing = await self.get_room_by_code(code)
if not existing:
return code
raise ValueError("无法生成唯一房间码,请稍后重试")
async def _get_next_sequence(self, room_id: int) -> int:
"""获取下一个消息序号"""
result = await self.db.execute(
select(PracticeRoomMessage.sequence)
.where(PracticeRoomMessage.room_id == room_id)
.order_by(PracticeRoomMessage.sequence.desc())
.limit(1)
)
last_seq = result.scalar_one_or_none()
return (last_seq or 0) + 1
async def _add_system_message(
self,
room_id: int,
content: str,
msg_type: str,
user_id: Optional[int] = None
) -> PracticeRoomMessage:
"""添加系统消息"""
sequence = await self._get_next_sequence(room_id)
message = PracticeRoomMessage(
room_id=room_id,
user_id=user_id,
message_type=msg_type,
content=content,
sequence=sequence
)
self.db.add(message)
await self.db.commit()
await self.db.refresh(message)
return message
# ==================== 便捷函数 ====================
async def create_practice_room(
db: AsyncSession,
host_user_id: int,
**kwargs
) -> PracticeRoom:
"""便捷函数:创建房间"""
service = PracticeRoomService(db)
return await service.create_room(host_user_id, **kwargs)
async def join_practice_room(
db: AsyncSession,
room_code: str,
user_id: int
) -> PracticeRoom:
"""便捷函数:加入房间"""
service = PracticeRoomService(db)
return await service.join_room(room_code, user_id)