788 lines
28 KiB
Python
788 lines
28 KiB
Python
|
|
# =============================================================================
|
|||
|
|
# 企微IT智能服务台 — 测验题目 AI 生成服务
|
|||
|
|
# =============================================================================
|
|||
|
|
# 说明:复用 Dify Wingman API(OpenAI-compatible 格式),自动生成:
|
|||
|
|
# 1. IT 知识题(7 类别,排队等待期间向员工推送)
|
|||
|
|
# 2. 诊断题(基于近期工单模式,帮助员工自检问题)
|
|||
|
|
#
|
|||
|
|
# 生成策略:
|
|||
|
|
# - AI 生成的所有题目 is_active=False,需管理员审批后激活
|
|||
|
|
# - 种子数据(seed_quiz.py 调用)is_active=True,bootstrap 例外
|
|||
|
|
# - 定时任务每日 3:00 生成新题 + 淘汰陈旧题
|
|||
|
|
#
|
|||
|
|
# 降级策略:
|
|||
|
|
# - Dify 不可用时返回空结果(不抛异常),调用方决定是否重试
|
|||
|
|
# - JSON 解析三层降级:直接 parse → ```json 代码块 → [..] 提取
|
|||
|
|
# - 单题校验失败跳过,不影响其他题
|
|||
|
|
# =============================================================================
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import logging
|
|||
|
|
import re
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
from sqlalchemy import select, func
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
|
|||
|
|
from app.config import settings
|
|||
|
|
from app.models.quiz import QuizQuestion, QuizAnswer
|
|||
|
|
from app.models.conversation import Conversation
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 常量
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
# 合法的题目类别
|
|||
|
|
VALID_CATEGORIES = {"network", "vpn", "email", "system", "printer", "security", "office"}
|
|||
|
|
|
|||
|
|
# 合法的难度值
|
|||
|
|
VALID_DIFFICULTIES = {"easy", "medium", "hard"}
|
|||
|
|
|
|||
|
|
# 类别中英文映射(用于 Dify prompt)
|
|||
|
|
CATEGORY_MAP: Dict[str, Tuple[str, str]] = {
|
|||
|
|
"network": ("网络", "局域网/WiFi/网络配置/连通性/IP分配问题"),
|
|||
|
|
"vpn": ("VPN", "VPN连接/零信任aTrust/远程接入/认证失败问题"),
|
|||
|
|
"email": ("邮箱", "企业邮箱/Outlook/邮件配置/收发失败问题"),
|
|||
|
|
"system": ("系统", "Windows/Mac系统/蓝屏/性能优化/系统更新问题"),
|
|||
|
|
"printer": ("打印机", "打印机连接/共享/驱动/扫描/卡纸问题"),
|
|||
|
|
"security": ("安全", "火绒杀毒/防火墙/密码策略/钓鱼邮件/数据安全"),
|
|||
|
|
"office": ("办公软件", "WPS/Office/Excel/Word/PPT/企微文档协同"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# Dify Prompt 模板
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
_KNOWLEDGE_SYSTEM_PROMPT = (
|
|||
|
|
"你是一个企业IT支持测验题目生成器。"
|
|||
|
|
"你的任务是生成高质量的多选题,帮助员工在排队等待期间学习IT知识。"
|
|||
|
|
"题目应贴近企业办公场景(含VPN/火绒杀毒/企微/打印机等),实用且准确。"
|
|||
|
|
"必须以JSON数组格式输出,不要包含任何其他文字。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_KNOWLEDGE_USER_TEMPLATE = (
|
|||
|
|
"请生成 {count} 道关于「{category_cn}」类别的IT知识选择题。\n\n"
|
|||
|
|
"要求:\n"
|
|||
|
|
"1. 每题4个选项(A/B/C/D),只有1个正确答案\n"
|
|||
|
|
"2. 难度分布:约40%简单、40%中等、20%困难\n"
|
|||
|
|
"3. 解析要简明扼要,说明正确答案的原因\n"
|
|||
|
|
"4. 题目不要重复,覆盖该类别的不同知识点\n\n"
|
|||
|
|
"类别说明:{category_cn} —— {category_desc}\n\n"
|
|||
|
|
"输出格式(严格JSON数组,不要markdown代码块):\n"
|
|||
|
|
'[{{"question": "题目文本", '
|
|||
|
|
'"options": ["选项A", "选项B", "选项C", "选项D"], '
|
|||
|
|
'"correct_index": 0, '
|
|||
|
|
'"explanation": "解析说明", '
|
|||
|
|
'"difficulty": "medium"}}]\n\n'
|
|||
|
|
"注意:correct_index 是正确选项的索引(0-3),difficulty 只能是 easy/medium/hard。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_DIAGNOSTIC_SYSTEM_PROMPT = (
|
|||
|
|
"你是一个IT故障诊断题目生成器。"
|
|||
|
|
"你的任务是基于近期工单模式,生成诊断性选择题,"
|
|||
|
|
"帮助员工在排队期间自检问题,答案将提供给坐席参考。"
|
|||
|
|
"必须以JSON数组格式输出,不要包含任何其他文字。"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_DIAGNOSTIC_USER_TEMPLATE = (
|
|||
|
|
"请基于以下近期工单摘要,生成 {count} 道诊断性选择题。\n\n"
|
|||
|
|
"问题类别:{problem_category}\n\n"
|
|||
|
|
"近期工单摘要:\n{ticket_context}\n\n"
|
|||
|
|
"要求:\n"
|
|||
|
|
"1. 题目应帮助员工自检当前问题,如\"你的VPN客户端显示什么错误码?\"\n"
|
|||
|
|
"2. 选项应覆盖常见情况,便于坐席快速定位问题\n"
|
|||
|
|
"3. 每题4个选项,correct_index 指向最可能的选项\n"
|
|||
|
|
"4. difficulty 统一为 medium\n\n"
|
|||
|
|
"输出格式(严格JSON数组):\n"
|
|||
|
|
'[{{"question": "诊断题目", '
|
|||
|
|
'"options": ["选项A", "选项B", "选项C", "选项D"], '
|
|||
|
|
'"correct_index": 0, '
|
|||
|
|
'"explanation": "此选项通常表示...", '
|
|||
|
|
'"difficulty": "medium"}}]'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class QuizGenerationService:
|
|||
|
|
"""测验题目 AI 生成服务。
|
|||
|
|
|
|||
|
|
复用 Dify Wingman API(OpenAI-compatible 格式),
|
|||
|
|
生成知识题、诊断题,并管理陈旧题目的自动淘汰。
|
|||
|
|
|
|||
|
|
所有 AI 生成的题目默认 is_active=False,需管理员审批。
|
|||
|
|
种子数据调用时可通过参数设为 is_active=True。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
"""初始化服务,读取 Dify API 配置。
|
|||
|
|
|
|||
|
|
优先使用 Wingman 专用配置;若未配置则 fallback 到主 Dify API。
|
|||
|
|
"""
|
|||
|
|
self.api_url = settings.dify_wingman_api_url or settings.dify_api_url
|
|||
|
|
self.api_key = settings.dify_wingman_api_key or settings.dify_api_key
|
|||
|
|
self.timeout = settings.dify_wingman_timeout or settings.dify_timeout
|
|||
|
|
self._client: Optional[httpx.AsyncClient] = None
|
|||
|
|
|
|||
|
|
# ==================================================================
|
|||
|
|
# httpx 客户端管理
|
|||
|
|
# ==================================================================
|
|||
|
|
|
|||
|
|
async def _get_client(self) -> httpx.AsyncClient:
|
|||
|
|
"""获取 httpx 异步客户端(懒加载,复用连接池)。"""
|
|||
|
|
if self._client is None or self._client.is_closed:
|
|||
|
|
self._client = httpx.AsyncClient(
|
|||
|
|
timeout=httpx.Timeout(self.timeout),
|
|||
|
|
headers={
|
|||
|
|
"Authorization": f"Bearer {self.api_key}",
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
return self._client
|
|||
|
|
|
|||
|
|
async def close(self):
|
|||
|
|
"""关闭 httpx 客户端,释放连接池资源。"""
|
|||
|
|
if self._client and not self._client.is_closed:
|
|||
|
|
await self._client.aclose()
|
|||
|
|
self._client = None
|
|||
|
|
|
|||
|
|
# ==================================================================
|
|||
|
|
# 公开方法
|
|||
|
|
# ==================================================================
|
|||
|
|
|
|||
|
|
async def generate_knowledge_questions_batch(
|
|||
|
|
self,
|
|||
|
|
db: AsyncSession,
|
|||
|
|
category: str,
|
|||
|
|
count: int = 5,
|
|||
|
|
is_active: bool = False,
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""批量生成知识题。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
db: 数据库会话
|
|||
|
|
category: 题目类别(network/vpn/email/system/printer/security/office)
|
|||
|
|
count: 生成数量(默认 5)
|
|||
|
|
is_active: 是否直接激活(种子数据 True,定时任务 False)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict: {
|
|||
|
|
"success_count": int,
|
|||
|
|
"failed_count": int,
|
|||
|
|
"errors": List[str],
|
|||
|
|
"questions": List[Dict], # 生成的题目摘要
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
errors: List[str] = []
|
|||
|
|
questions_created: List[Dict[str, Any]] = []
|
|||
|
|
|
|||
|
|
# 校验类别
|
|||
|
|
if category not in VALID_CATEGORIES:
|
|||
|
|
return {
|
|||
|
|
"success_count": 0,
|
|||
|
|
"failed_count": count,
|
|||
|
|
"errors": [f"无效类别: {category}"],
|
|||
|
|
"questions": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 构建并调用 Dify
|
|||
|
|
category_cn, category_desc = CATEGORY_MAP[category]
|
|||
|
|
user_prompt = _KNOWLEDGE_USER_TEMPLATE.format(
|
|||
|
|
count=count,
|
|||
|
|
category_cn=category_cn,
|
|||
|
|
category_desc=category_desc,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
raw_response = await self._call_dify(
|
|||
|
|
system_prompt=_KNOWLEDGE_SYSTEM_PROMPT,
|
|||
|
|
user_prompt=user_prompt,
|
|||
|
|
temperature=0.7, # 较高温度保证多样性
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if raw_response is None:
|
|||
|
|
return {
|
|||
|
|
"success_count": 0,
|
|||
|
|
"failed_count": count,
|
|||
|
|
"errors": ["Dify API 调用失败(超时或HTTP错误)"],
|
|||
|
|
"questions": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 解析 JSON 数组
|
|||
|
|
items = self._parse_json_array(raw_response)
|
|||
|
|
if items is None:
|
|||
|
|
logger.warning(f"知识题 JSON 解析失败 [{category}]: {raw_response[:200]}")
|
|||
|
|
return {
|
|||
|
|
"success_count": 0,
|
|||
|
|
"failed_count": count,
|
|||
|
|
"errors": ["AI 返回内容无法解析为 JSON 数组"],
|
|||
|
|
"questions": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 逐条校验并插入
|
|||
|
|
success_count = 0
|
|||
|
|
failed_count = 0
|
|||
|
|
|
|||
|
|
for i, item in enumerate(items):
|
|||
|
|
is_valid, err_msg, normalized = self._validate_question(
|
|||
|
|
item, category, q_type="knowledge"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if not is_valid:
|
|||
|
|
errors.append(f"题[{i}]: {err_msg}")
|
|||
|
|
failed_count += 1
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 去重检查
|
|||
|
|
is_dup = await self._check_duplicate(
|
|||
|
|
db, normalized["question"], category
|
|||
|
|
)
|
|||
|
|
if is_dup:
|
|||
|
|
errors.append(f"题[{i}]: 与已有题目重复,跳过")
|
|||
|
|
failed_count += 1
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 插入数据库
|
|||
|
|
question = QuizQuestion(
|
|||
|
|
type="knowledge",
|
|||
|
|
category=category,
|
|||
|
|
difficulty=normalized["difficulty"],
|
|||
|
|
question=normalized["question"],
|
|||
|
|
options=normalized["options"],
|
|||
|
|
correct_index=normalized["correct_index"],
|
|||
|
|
explanation=normalized["explanation"],
|
|||
|
|
is_active=is_active,
|
|||
|
|
created_at=datetime.now(),
|
|||
|
|
)
|
|||
|
|
db.add(question)
|
|||
|
|
await db.flush() # 获取 id
|
|||
|
|
|
|||
|
|
questions_created.append({
|
|||
|
|
"id": question.id,
|
|||
|
|
"question": question.question[:80],
|
|||
|
|
"difficulty": question.difficulty,
|
|||
|
|
"is_active": is_active,
|
|||
|
|
})
|
|||
|
|
success_count += 1
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"知识题生成 [{category}]: 成功 {success_count}, 失败 {failed_count}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"success_count": success_count,
|
|||
|
|
"failed_count": failed_count,
|
|||
|
|
"errors": errors,
|
|||
|
|
"questions": questions_created,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def generate_diagnostic_questions_batch(
|
|||
|
|
self,
|
|||
|
|
db: AsyncSession,
|
|||
|
|
problem_category: str,
|
|||
|
|
count: int = 3,
|
|||
|
|
ticket_summaries: Optional[List[str]] = None,
|
|||
|
|
is_active: bool = False,
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""批量生成诊断题(基于近期工单模式)。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
db: 数据库会话
|
|||
|
|
problem_category: 问题类别(如 "vpn_disconnect")
|
|||
|
|
count: 生成数量(默认 3)
|
|||
|
|
ticket_summaries: 近期工单摘要列表(作为 Dify 上下文)
|
|||
|
|
is_active: 是否直接激活
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict: 同 generate_knowledge_questions_batch
|
|||
|
|
"""
|
|||
|
|
errors: List[str] = []
|
|||
|
|
questions_created: List[Dict[str, Any]] = []
|
|||
|
|
|
|||
|
|
# 构建工单上下文
|
|||
|
|
if ticket_summaries:
|
|||
|
|
ticket_context = "\n".join(
|
|||
|
|
f"- {s}" for s in ticket_summaries[:20]
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
ticket_context = "(暂无近期工单数据,请基于常见问题生成)"
|
|||
|
|
|
|||
|
|
# 构建并调用 Dify
|
|||
|
|
user_prompt = _DIAGNOSTIC_USER_TEMPLATE.format(
|
|||
|
|
count=count,
|
|||
|
|
problem_category=problem_category,
|
|||
|
|
ticket_context=ticket_context,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
raw_response = await self._call_dify(
|
|||
|
|
system_prompt=_DIAGNOSTIC_SYSTEM_PROMPT,
|
|||
|
|
user_prompt=user_prompt,
|
|||
|
|
temperature=0.5, # 较低温度,诊断题需要准确
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if raw_response is None:
|
|||
|
|
return {
|
|||
|
|
"success_count": 0,
|
|||
|
|
"failed_count": count,
|
|||
|
|
"errors": ["Dify API 调用失败"],
|
|||
|
|
"questions": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 解析 JSON
|
|||
|
|
items = self._parse_json_array(raw_response)
|
|||
|
|
if items is None:
|
|||
|
|
logger.warning(f"诊断题 JSON 解析失败 [{problem_category}]")
|
|||
|
|
return {
|
|||
|
|
"success_count": 0,
|
|||
|
|
"failed_count": count,
|
|||
|
|
"errors": ["AI 返回内容无法解析为 JSON 数组"],
|
|||
|
|
"questions": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 逐条校验并插入
|
|||
|
|
success_count = 0
|
|||
|
|
failed_count = 0
|
|||
|
|
|
|||
|
|
# 推断诊断题的 category(从 problem_category 提取)
|
|||
|
|
# problem_category 格式如 "vpn_disconnect" → category="vpn"
|
|||
|
|
inferred_category = problem_category.split("_")[0] if problem_category else "system"
|
|||
|
|
if inferred_category not in VALID_CATEGORIES:
|
|||
|
|
inferred_category = "system"
|
|||
|
|
|
|||
|
|
for i, item in enumerate(items):
|
|||
|
|
is_valid, err_msg, normalized = self._validate_question(
|
|||
|
|
item, inferred_category, q_type="diagnostic"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if not is_valid:
|
|||
|
|
errors.append(f"诊断题[{i}]: {err_msg}")
|
|||
|
|
failed_count += 1
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 去重
|
|||
|
|
is_dup = await self._check_duplicate(
|
|||
|
|
db, normalized["question"], inferred_category
|
|||
|
|
)
|
|||
|
|
if is_dup:
|
|||
|
|
errors.append(f"诊断题[{i}]: 重复,跳过")
|
|||
|
|
failed_count += 1
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 插入
|
|||
|
|
question = QuizQuestion(
|
|||
|
|
type="diagnostic",
|
|||
|
|
category=inferred_category,
|
|||
|
|
problem_category=problem_category,
|
|||
|
|
difficulty=normalized["difficulty"],
|
|||
|
|
question=normalized["question"],
|
|||
|
|
options=normalized["options"],
|
|||
|
|
correct_index=normalized["correct_index"],
|
|||
|
|
explanation=normalized["explanation"],
|
|||
|
|
is_active=is_active,
|
|||
|
|
created_at=datetime.now(),
|
|||
|
|
)
|
|||
|
|
db.add(question)
|
|||
|
|
await db.flush()
|
|||
|
|
|
|||
|
|
questions_created.append({
|
|||
|
|
"id": question.id,
|
|||
|
|
"question": question.question[:80],
|
|||
|
|
"problem_category": problem_category,
|
|||
|
|
"is_active": is_active,
|
|||
|
|
})
|
|||
|
|
success_count += 1
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"诊断题生成 [{problem_category}]: 成功 {success_count}, 失败 {failed_count}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"success_count": success_count,
|
|||
|
|
"failed_count": failed_count,
|
|||
|
|
"errors": errors,
|
|||
|
|
"questions": questions_created,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async def deactivate_stale_questions(
|
|||
|
|
self,
|
|||
|
|
db: AsyncSession,
|
|||
|
|
threshold: float = 0.8,
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""停用被过多员工答过的陈旧题目。
|
|||
|
|
|
|||
|
|
当一道题被 >threshold 比例的活跃员工(近30天有答题记录)答过时,
|
|||
|
|
自动停用(is_active=True → False)。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
db: 数据库会话
|
|||
|
|
threshold: 答题覆盖率阈值(0-1,默认 0.8)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict: {
|
|||
|
|
"deactivated_count": int,
|
|||
|
|
"total_active_employees": int,
|
|||
|
|
"deactivated_questions": List[Dict],
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
# 1. 统计近30天活跃员工总数
|
|||
|
|
thirty_days_ago = datetime.now() - timedelta(days=30)
|
|||
|
|
total_result = await db.execute(
|
|||
|
|
select(func.count(func.distinct(QuizAnswer.employee_id))).where(
|
|||
|
|
QuizAnswer.created_at > thirty_days_ago
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
total_employees = total_result.scalar() or 0
|
|||
|
|
|
|||
|
|
if total_employees == 0:
|
|||
|
|
logger.debug("无活跃员工答题记录,跳过陈旧题淘汰")
|
|||
|
|
return {
|
|||
|
|
"deactivated_count": 0,
|
|||
|
|
"total_active_employees": 0,
|
|||
|
|
"deactivated_questions": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 2. 统计每道题的答题人数
|
|||
|
|
answer_stats = await db.execute(
|
|||
|
|
select(
|
|||
|
|
QuizAnswer.question_id,
|
|||
|
|
func.count(func.distinct(QuizAnswer.employee_id)).label("answered_count"),
|
|||
|
|
)
|
|||
|
|
.where(QuizAnswer.created_at > thirty_days_ago)
|
|||
|
|
.group_by(QuizAnswer.question_id)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
deactivated: List[Dict[str, Any]] = []
|
|||
|
|
threshold_count = total_employees * threshold
|
|||
|
|
|
|||
|
|
for row in answer_stats:
|
|||
|
|
if row.answered_count >= threshold_count:
|
|||
|
|
# 查询并停用该题
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(QuizQuestion).where(
|
|||
|
|
QuizQuestion.id == row.question_id,
|
|||
|
|
QuizQuestion.is_active == True, # noqa: E712
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
question = result.scalar_one_or_none()
|
|||
|
|
if question:
|
|||
|
|
question.is_active = False
|
|||
|
|
deactivated.append({
|
|||
|
|
"question_id": question.id,
|
|||
|
|
"question_text": question.question[:80],
|
|||
|
|
"answered_count": row.answered_count,
|
|||
|
|
"coverage": round(row.answered_count / total_employees, 2),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
await db.flush()
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"陈旧题停用: {len(deactivated)} 道 "
|
|||
|
|
f"(活跃员工 {total_employees} 人, 阈值 {threshold})"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"deactivated_count": len(deactivated),
|
|||
|
|
"total_active_employees": total_employees,
|
|||
|
|
"deactivated_questions": deactivated,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ==================================================================
|
|||
|
|
# 内部方法 — Dify 调用
|
|||
|
|
# ==================================================================
|
|||
|
|
|
|||
|
|
async def _call_dify(
|
|||
|
|
self,
|
|||
|
|
system_prompt: str,
|
|||
|
|
user_prompt: str,
|
|||
|
|
temperature: float = 0.7,
|
|||
|
|
) -> Optional[str]:
|
|||
|
|
"""调用 Dify API(OpenAI-compatible 格式)。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
system_prompt: 系统提示词
|
|||
|
|
user_prompt: 用户提示词
|
|||
|
|
temperature: 温度(0-1,越高越有创意)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Optional[str]: AI 返回文本,失败返回 None
|
|||
|
|
"""
|
|||
|
|
payload = {
|
|||
|
|
"model": "Chat",
|
|||
|
|
"messages": [
|
|||
|
|
{"role": "system", "content": system_prompt},
|
|||
|
|
{"role": "user", "content": user_prompt},
|
|||
|
|
],
|
|||
|
|
"stream": False,
|
|||
|
|
"temperature": temperature,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
client = await self._get_client()
|
|||
|
|
logger.info(f"调用 Dify 生成题目: prompt_length={len(user_prompt)}")
|
|||
|
|
response = await client.post(self.api_url, json=payload)
|
|||
|
|
response.raise_for_status()
|
|||
|
|
data = response.json()
|
|||
|
|
|
|||
|
|
# 解析 OpenAI 兼容格式返回
|
|||
|
|
choices = data.get("choices", [])
|
|||
|
|
if not choices:
|
|||
|
|
logger.warning("Dify API 返回空 choices")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
content = choices[0]["message"]["content"]
|
|||
|
|
logger.info(f"Dify API 返回: content_length={len(content)}")
|
|||
|
|
return content
|
|||
|
|
|
|||
|
|
except httpx.TimeoutException:
|
|||
|
|
logger.error("Dify API 超时(题目生成)")
|
|||
|
|
return None
|
|||
|
|
except httpx.HTTPStatusError as e:
|
|||
|
|
logger.error(f"Dify API HTTP 错误: status={e.response.status_code}")
|
|||
|
|
return None
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Dify API 调用失败: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# ==================================================================
|
|||
|
|
# 内部方法 — JSON 解析
|
|||
|
|
# ==================================================================
|
|||
|
|
|
|||
|
|
def _parse_json_array(self, content: str) -> Optional[List[Dict[str, Any]]]:
|
|||
|
|
"""解析 AI 返回的 JSON 数组。
|
|||
|
|
|
|||
|
|
三层降级解析:
|
|||
|
|
1. 直接 json.loads
|
|||
|
|
2. 提取 ```json ... ``` 代码块
|
|||
|
|
3. 查找第一个 [ 到最后一个 ]
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
content: AI 返回的原始文本
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Optional[List[Dict]]: 解析成功返回列表,失败返回 None
|
|||
|
|
"""
|
|||
|
|
if not content:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# 尝试 1:直接解析
|
|||
|
|
try:
|
|||
|
|
result = json.loads(content)
|
|||
|
|
if isinstance(result, list):
|
|||
|
|
return result
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# 尝试 2:提取 markdown 代码块中的 JSON
|
|||
|
|
json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', content, re.DOTALL)
|
|||
|
|
if json_match:
|
|||
|
|
try:
|
|||
|
|
result = json.loads(json_match.group(1).strip())
|
|||
|
|
if isinstance(result, list):
|
|||
|
|
return result
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# 尝试 3:查找第一个 [ 到最后一个 ]
|
|||
|
|
start = content.find('[')
|
|||
|
|
end = content.rfind(']')
|
|||
|
|
if start != -1 and end != -1 and end > start:
|
|||
|
|
try:
|
|||
|
|
result = json.loads(content[start:end + 1])
|
|||
|
|
if isinstance(result, list):
|
|||
|
|
return result
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
logger.warning(f"JSON 数组解析失败: {content[:200]}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# ==================================================================
|
|||
|
|
# 内部方法 — 题目校验
|
|||
|
|
# ==================================================================
|
|||
|
|
|
|||
|
|
def _validate_question(
|
|||
|
|
self,
|
|||
|
|
item: Dict[str, Any],
|
|||
|
|
category: str,
|
|||
|
|
q_type: str = "knowledge",
|
|||
|
|
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
|
|||
|
|
"""校验单个题目字段。
|
|||
|
|
|
|||
|
|
校验规则:
|
|||
|
|
- question: 非空字符串,≥5 字符
|
|||
|
|
- options: 列表,恰好 4 个非空字符串
|
|||
|
|
- correct_index: 整数,0-3 范围
|
|||
|
|
- explanation: 非空字符串
|
|||
|
|
- difficulty: 枚举值 easy/medium/hard
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
item: 待校验的题目字典
|
|||
|
|
category: 题目类别
|
|||
|
|
q_type: 题目类型(knowledge/diagnostic)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Tuple[is_valid, error_msg, normalized_data]
|
|||
|
|
"""
|
|||
|
|
# 1. 检查必需字段
|
|||
|
|
required_fields = {"question", "options", "correct_index", "explanation", "difficulty"}
|
|||
|
|
missing = required_fields - set(item.keys())
|
|||
|
|
if missing:
|
|||
|
|
return False, f"缺少字段: {missing}", None
|
|||
|
|
|
|||
|
|
# 2. question 非空字符串
|
|||
|
|
question_text = item.get("question")
|
|||
|
|
if not isinstance(question_text, str) or len(question_text.strip()) < 5:
|
|||
|
|
return False, "question 必须是非空字符串(≥5字符)", None
|
|||
|
|
|
|||
|
|
# 3. options 恰好 4 个非空字符串
|
|||
|
|
options = item.get("options")
|
|||
|
|
if not isinstance(options, list) or len(options) != 4:
|
|||
|
|
opt_count = len(options) if isinstance(options, list) else "非列表"
|
|||
|
|
return False, f"options 必须是4个选项的列表, 实际: {opt_count}", None
|
|||
|
|
|
|||
|
|
for i, opt in enumerate(options):
|
|||
|
|
if not isinstance(opt, str) or not opt.strip():
|
|||
|
|
return False, f"option[{i}] 必须是非空字符串", None
|
|||
|
|
|
|||
|
|
# 4. correct_index 0-3 整数
|
|||
|
|
correct_index = item.get("correct_index")
|
|||
|
|
if not isinstance(correct_index, int) or correct_index < 0 or correct_index > 3:
|
|||
|
|
return False, f"correct_index 必须是0-3的整数, 实际: {correct_index}", None
|
|||
|
|
|
|||
|
|
# 5. difficulty 枚举
|
|||
|
|
difficulty = item.get("difficulty", "medium")
|
|||
|
|
if difficulty not in VALID_DIFFICULTIES:
|
|||
|
|
return False, f"difficulty 无效: {difficulty}, 应为 {VALID_DIFFICULTIES}", None
|
|||
|
|
|
|||
|
|
# 6. explanation 非空
|
|||
|
|
explanation = item.get("explanation", "")
|
|||
|
|
if not isinstance(explanation, str) or not explanation.strip():
|
|||
|
|
return False, "explanation 不能为空", None
|
|||
|
|
|
|||
|
|
# 标准化数据
|
|||
|
|
normalized = {
|
|||
|
|
"type": q_type,
|
|||
|
|
"category": category,
|
|||
|
|
"difficulty": difficulty,
|
|||
|
|
"question": question_text.strip(),
|
|||
|
|
"options": [opt.strip() for opt in options],
|
|||
|
|
"correct_index": correct_index,
|
|||
|
|
"explanation": explanation.strip(),
|
|||
|
|
}
|
|||
|
|
return True, "", normalized
|
|||
|
|
|
|||
|
|
# ==================================================================
|
|||
|
|
# 内部方法 — 去重检查
|
|||
|
|
# ==================================================================
|
|||
|
|
|
|||
|
|
async def _check_duplicate(
|
|||
|
|
self,
|
|||
|
|
db: AsyncSession,
|
|||
|
|
question_text: str,
|
|||
|
|
category: str,
|
|||
|
|
) -> bool:
|
|||
|
|
"""检查题目是否重复(前 50 字符 + category 匹配)。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
db: 数据库会话
|
|||
|
|
question_text: 题目文本
|
|||
|
|
category: 题目类别
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
bool: True 表示已存在重复题目
|
|||
|
|
"""
|
|||
|
|
# 取前 50 个字符做模糊匹配
|
|||
|
|
prefix = question_text[:50]
|
|||
|
|
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(func.count(QuizQuestion.id)).where(
|
|||
|
|
QuizQuestion.category == category,
|
|||
|
|
QuizQuestion.question.like(f"{prefix}%"),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
count = result.scalar() or 0
|
|||
|
|
return count > 0
|
|||
|
|
|
|||
|
|
# ==================================================================
|
|||
|
|
# 内部方法 — 近期工单摘要
|
|||
|
|
# ==================================================================
|
|||
|
|
|
|||
|
|
async def _get_recent_ticket_summaries(
|
|||
|
|
self,
|
|||
|
|
db: AsyncSession,
|
|||
|
|
days: int = 7,
|
|||
|
|
limit: int = 20,
|
|||
|
|
) -> List[Dict[str, Any]]:
|
|||
|
|
"""获取近期已解决工单的摘要和标签(用于诊断题生成上下文)。
|
|||
|
|
|
|||
|
|
查询条件:
|
|||
|
|
- Conversation.status == 'resolved'
|
|||
|
|
- created_at > now - days
|
|||
|
|
- 取 last_message_summary 和 tags
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
db: 数据库会话
|
|||
|
|
days: 查询天数(默认 7)
|
|||
|
|
limit: 返回数量上限(默认 20)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
List[Dict]: [{"summary": "...", "tags": [...], "category_hint": "..."}]
|
|||
|
|
"""
|
|||
|
|
cutoff = datetime.now() - timedelta(days=days)
|
|||
|
|
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(
|
|||
|
|
Conversation.id,
|
|||
|
|
Conversation.last_message_summary,
|
|||
|
|
Conversation.tags,
|
|||
|
|
)
|
|||
|
|
.where(
|
|||
|
|
Conversation.status == "resolved",
|
|||
|
|
Conversation.created_at > cutoff,
|
|||
|
|
)
|
|||
|
|
.order_by(Conversation.created_at.desc())
|
|||
|
|
.limit(limit)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
summaries: List[Dict[str, Any]] = []
|
|||
|
|
for row in result:
|
|||
|
|
summary_text = row.last_message_summary or ""
|
|||
|
|
# tags 是 Dict 类型,如 {"hand_raise": true, "emotion": "angry"}
|
|||
|
|
tags_dict = row.tags if isinstance(row.tags, dict) else {}
|
|||
|
|
tag_keys = list(tags_dict.keys())
|
|||
|
|
|
|||
|
|
# 从 tags 键名推断 category_hint
|
|||
|
|
category_hint = ""
|
|||
|
|
for tag_key in tag_keys:
|
|||
|
|
tag_lower = tag_key.lower()
|
|||
|
|
for cat in VALID_CATEGORIES:
|
|||
|
|
if cat in tag_lower:
|
|||
|
|
category_hint = cat
|
|||
|
|
break
|
|||
|
|
if category_hint:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
summaries.append({
|
|||
|
|
"summary": summary_text,
|
|||
|
|
"tags": tag_keys, # 返回 tag 键名列表
|
|||
|
|
"category_hint": category_hint,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return summaries
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 单例管理
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
_quiz_gen_service: Optional[QuizGenerationService] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_quiz_generation_service() -> QuizGenerationService:
|
|||
|
|
"""获取 QuizGenerationService 单例实例。"""
|
|||
|
|
global _quiz_gen_service
|
|||
|
|
if _quiz_gen_service is None:
|
|||
|
|
_quiz_gen_service = QuizGenerationService()
|
|||
|
|
return _quiz_gen_service
|