- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
380 lines
11 KiB
Python
380 lines
11 KiB
Python
"""
|
||
陪练场景准备服务 - Python 原生实现
|
||
|
||
功能:
|
||
- 根据课程ID获取知识点
|
||
- 调用 AI 生成陪练场景配置
|
||
- 解析并返回结构化场景数据
|
||
|
||
提供稳定可靠的陪练场景提取能力。
|
||
"""
|
||
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.exceptions import ExternalServiceError
|
||
|
||
from .ai_service import AIService, AIResponse
|
||
from .llm_json_parser import parse_with_fallback, clean_llm_output
|
||
from .prompts.practice_scene_prompts import (
|
||
SYSTEM_PROMPT,
|
||
USER_PROMPT,
|
||
PRACTICE_SCENE_SCHEMA,
|
||
DEFAULT_SCENE_TYPE,
|
||
DEFAULT_DIFFICULTY,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ==================== 数据结构 ====================
|
||
|
||
@dataclass
|
||
class PracticeScene:
|
||
"""陪练场景数据结构"""
|
||
name: str
|
||
description: str
|
||
background: str
|
||
ai_role: str
|
||
objectives: List[str]
|
||
keywords: List[str]
|
||
type: str = DEFAULT_SCENE_TYPE
|
||
difficulty: str = DEFAULT_DIFFICULTY
|
||
|
||
|
||
@dataclass
|
||
class PracticeSceneResult:
|
||
"""陪练场景生成结果"""
|
||
success: bool
|
||
scene: Optional[PracticeScene] = None
|
||
raw_response: Dict[str, Any] = field(default_factory=dict)
|
||
ai_provider: str = ""
|
||
ai_model: str = ""
|
||
ai_tokens: int = 0
|
||
ai_latency_ms: int = 0
|
||
knowledge_points_count: int = 0
|
||
error: str = ""
|
||
|
||
|
||
# ==================== 服务类 ====================
|
||
|
||
class PracticeSceneService:
|
||
"""
|
||
陪练场景准备服务
|
||
|
||
使用 Python 原生实现。
|
||
|
||
使用示例:
|
||
```python
|
||
service = PracticeSceneService()
|
||
result = await service.prepare_practice_knowledge(
|
||
db=db_session,
|
||
course_id=1
|
||
)
|
||
if result.success:
|
||
print(result.scene.name)
|
||
print(result.scene.objectives)
|
||
```
|
||
"""
|
||
|
||
def __init__(self):
|
||
"""初始化服务"""
|
||
self.ai_service = AIService(module_code="practice_scene")
|
||
|
||
async def prepare_practice_knowledge(
|
||
self,
|
||
db: AsyncSession,
|
||
course_id: int
|
||
) -> PracticeSceneResult:
|
||
"""
|
||
准备陪练所需的知识内容并生成场景
|
||
|
||
陪练知识准备的 Python 实现。
|
||
|
||
Args:
|
||
db: 数据库会话(支持多租户,由调用方传入对应租户的数据库连接)
|
||
course_id: 课程ID
|
||
|
||
Returns:
|
||
PracticeSceneResult: 包含场景配置和元信息的结果对象
|
||
"""
|
||
try:
|
||
logger.info(f"开始陪练知识准备 - course_id: {course_id}")
|
||
|
||
# 1. 查询知识点
|
||
knowledge_points = await self._fetch_knowledge_points(db, course_id)
|
||
|
||
if not knowledge_points:
|
||
logger.warning(f"课程没有知识点 - course_id: {course_id}")
|
||
return PracticeSceneResult(
|
||
success=False,
|
||
error=f"课程 {course_id} 没有可用的知识点"
|
||
)
|
||
|
||
logger.info(f"获取到 {len(knowledge_points)} 个知识点 - course_id: {course_id}")
|
||
|
||
# 2. 格式化知识点为文本
|
||
knowledge_text = self._format_knowledge_points(knowledge_points)
|
||
|
||
# 3. 调用 AI 生成场景
|
||
ai_response = await self._call_ai_generation(knowledge_text)
|
||
|
||
logger.info(
|
||
f"AI 生成完成 - provider: {ai_response.provider}, "
|
||
f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms"
|
||
)
|
||
|
||
# 4. 解析 JSON 结果
|
||
scene_data = self._parse_scene_response(ai_response.content)
|
||
|
||
if not scene_data:
|
||
logger.error(f"场景解析失败 - course_id: {course_id}")
|
||
return PracticeSceneResult(
|
||
success=False,
|
||
raw_response={"ai_output": ai_response.content},
|
||
ai_provider=ai_response.provider,
|
||
ai_model=ai_response.model,
|
||
ai_tokens=ai_response.total_tokens,
|
||
ai_latency_ms=ai_response.latency_ms,
|
||
knowledge_points_count=len(knowledge_points),
|
||
error="AI 输出解析失败"
|
||
)
|
||
|
||
# 5. 构建场景对象
|
||
scene = self._build_scene_object(scene_data)
|
||
|
||
logger.info(
|
||
f"陪练场景生成成功 - course_id: {course_id}, "
|
||
f"scene_name: {scene.name}, type: {scene.type}"
|
||
)
|
||
|
||
return PracticeSceneResult(
|
||
success=True,
|
||
scene=scene,
|
||
raw_response=scene_data,
|
||
ai_provider=ai_response.provider,
|
||
ai_model=ai_response.model,
|
||
ai_tokens=ai_response.total_tokens,
|
||
ai_latency_ms=ai_response.latency_ms,
|
||
knowledge_points_count=len(knowledge_points)
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"陪练知识准备失败 - course_id: {course_id}, error: {e}",
|
||
exc_info=True
|
||
)
|
||
return PracticeSceneResult(
|
||
success=False,
|
||
error=str(e)
|
||
)
|
||
|
||
async def _fetch_knowledge_points(
|
||
self,
|
||
db: AsyncSession,
|
||
course_id: int
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
从数据库获取课程知识点
|
||
|
||
获取课程知识点
|
||
"""
|
||
# 知识点查询 SQL:
|
||
# SELECT kp.name, kp.description
|
||
# FROM knowledge_points kp
|
||
# INNER JOIN course_materials cm ON kp.material_id = cm.id
|
||
# WHERE kp.course_id = {course_id}
|
||
# AND kp.is_deleted = 0
|
||
# AND cm.is_deleted = 0
|
||
# ORDER BY kp.id;
|
||
|
||
sql = text("""
|
||
SELECT kp.name, kp.description
|
||
FROM knowledge_points kp
|
||
INNER JOIN course_materials cm ON kp.material_id = cm.id
|
||
WHERE kp.course_id = :course_id
|
||
AND kp.is_deleted = 0
|
||
AND cm.is_deleted = 0
|
||
ORDER BY kp.id
|
||
""")
|
||
|
||
try:
|
||
result = await db.execute(sql, {"course_id": course_id})
|
||
rows = result.fetchall()
|
||
|
||
knowledge_points = []
|
||
for row in rows:
|
||
knowledge_points.append({
|
||
"name": row[0],
|
||
"description": row[1] or ""
|
||
})
|
||
|
||
return knowledge_points
|
||
|
||
except Exception as e:
|
||
logger.error(f"查询知识点失败: {e}")
|
||
raise ExternalServiceError(f"数据库查询失败: {e}")
|
||
|
||
def _format_knowledge_points(self, knowledge_points: List[Dict[str, Any]]) -> str:
|
||
"""
|
||
将知识点列表格式化为文本
|
||
|
||
Args:
|
||
knowledge_points: 知识点列表
|
||
|
||
Returns:
|
||
格式化后的文本
|
||
"""
|
||
lines = []
|
||
for i, kp in enumerate(knowledge_points, 1):
|
||
name = kp.get("name", "")
|
||
description = kp.get("description", "")
|
||
|
||
if description:
|
||
lines.append(f"{i}. {name}\n {description}")
|
||
else:
|
||
lines.append(f"{i}. {name}")
|
||
|
||
return "\n\n".join(lines)
|
||
|
||
async def _call_ai_generation(self, knowledge_text: str) -> AIResponse:
|
||
"""
|
||
调用 AI 生成陪练场景
|
||
|
||
Args:
|
||
knowledge_text: 格式化后的知识点文本
|
||
|
||
Returns:
|
||
AI 响应对象
|
||
"""
|
||
# 构建用户消息
|
||
user_message = USER_PROMPT.format(knowledge_points=knowledge_text)
|
||
|
||
messages = [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": user_message}
|
||
]
|
||
|
||
# 调用 AI(自动降级:4sapi.com → OpenRouter)
|
||
response = await self.ai_service.chat(
|
||
messages=messages,
|
||
temperature=0.7, # 适中的创意性
|
||
prompt_name="practice_scene_generation"
|
||
)
|
||
|
||
return response
|
||
|
||
def _parse_scene_response(self, ai_output: str) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
解析 AI 输出的场景 JSON
|
||
|
||
使用 LLM JSON Parser 进行多层兜底解析
|
||
|
||
Args:
|
||
ai_output: AI 原始输出
|
||
|
||
Returns:
|
||
解析后的字典,失败返回 None
|
||
"""
|
||
# 先清洗输出
|
||
cleaned_output, rules = clean_llm_output(ai_output)
|
||
if rules:
|
||
logger.debug(f"AI 输出已清洗: {rules}")
|
||
|
||
# 使用带 Schema 校验的解析
|
||
result = parse_with_fallback(
|
||
cleaned_output,
|
||
schema=PRACTICE_SCENE_SCHEMA,
|
||
default=None,
|
||
validate_schema=True,
|
||
on_error="none"
|
||
)
|
||
|
||
return result
|
||
|
||
def _build_scene_object(self, scene_data: Dict[str, Any]) -> PracticeScene:
|
||
"""
|
||
从解析的字典构建场景对象
|
||
|
||
Args:
|
||
scene_data: 解析后的场景数据
|
||
|
||
Returns:
|
||
PracticeScene 对象
|
||
"""
|
||
# 提取 scene 字段(JSON 格式为 {"scene": {...}})
|
||
scene = scene_data.get("scene", scene_data)
|
||
|
||
return PracticeScene(
|
||
name=scene.get("name", "陪练场景"),
|
||
description=scene.get("description", ""),
|
||
background=scene.get("background", ""),
|
||
ai_role=scene.get("ai_role", "AI扮演客户"),
|
||
objectives=scene.get("objectives", []),
|
||
keywords=scene.get("keywords", []),
|
||
type=scene.get("type", DEFAULT_SCENE_TYPE),
|
||
difficulty=scene.get("difficulty", DEFAULT_DIFFICULTY)
|
||
)
|
||
|
||
def scene_to_dict(self, scene: PracticeScene) -> Dict[str, Any]:
|
||
"""
|
||
将场景对象转换为字典
|
||
|
||
便于 API 响应序列化
|
||
|
||
Args:
|
||
scene: PracticeScene 对象
|
||
|
||
Returns:
|
||
字典格式的场景数据
|
||
"""
|
||
return {
|
||
"scene": {
|
||
"name": scene.name,
|
||
"description": scene.description,
|
||
"background": scene.background,
|
||
"ai_role": scene.ai_role,
|
||
"objectives": scene.objectives,
|
||
"keywords": scene.keywords,
|
||
"type": scene.type,
|
||
"difficulty": scene.difficulty
|
||
}
|
||
}
|
||
|
||
|
||
# ==================== 全局实例 ====================
|
||
|
||
practice_scene_service = PracticeSceneService()
|
||
|
||
|
||
# ==================== 便捷函数 ====================
|
||
|
||
async def prepare_practice_knowledge(
|
||
db: AsyncSession,
|
||
course_id: int
|
||
) -> PracticeSceneResult:
|
||
"""
|
||
准备陪练所需的知识内容(便捷函数)
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
course_id: 课程ID
|
||
|
||
Returns:
|
||
PracticeSceneResult 结果对象
|
||
"""
|
||
return await practice_scene_service.prepare_practice_knowledge(db, course_id)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|