feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
379
backend/app/services/ai/practice_scene_service.py
Normal file
379
backend/app/services/ai/practice_scene_service.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
陪练场景准备服务 - 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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user