449c6d4875
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS
## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS
## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)
## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code
## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)
## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过
## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务
## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记✅已实施
- 新增架构图/时序图/类图(mermaid)
## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
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
|