Files
wecom_it_smart_desk/backend/app/services/quiz_generation_service.py
T
Simon 449c6d4875 feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3
## 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分块上传
2026-07-13 02:17:03 +08:00

788 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — 测验题目 AI 生成服务
# =============================================================================
# 说明:复用 Dify Wingman APIOpenAI-compatible 格式),自动生成:
# 1. IT 知识题(7 类别,排队等待期间向员工推送)
# 2. 诊断题(基于近期工单模式,帮助员工自检问题)
#
# 生成策略:
# - AI 生成的所有题目 is_active=False,需管理员审批后激活
# - 种子数据(seed_quiz.py 调用)is_active=Truebootstrap 例外
# - 定时任务每日 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 APIOpenAI-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 APIOpenAI-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