Files
TalkingQ_Dashboard/scripts/badcase.js
“yuanmengy” 69785c214f first commit
2026-06-10 17:31:01 +08:00

675 lines
27 KiB
JavaScript
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.

/**
* TalkingQ Platform - Bad Case Management Page
*/
registerPage('badcase', function(container) {
const initialCases = [
{
id: 'BC-2026-001',
session: 'sess-6c1d',
device: 'DEV-002',
agent: '小Q故事机器人',
issue: '回答偏离话题,讲了不适合儿童的内容片段',
severity: 'high',
status: 'open',
reporter: '自动检测',
time: '2026-05-12 14:07',
tags: ['内容安全', '主题偏离'],
dialogue: {
user: '给我讲一个勇敢小朋友的故事',
assistant: '故事中出现了不适合儿童的危险情节描述。',
},
},
{
id: 'BC-2026-002',
session: 'sess-5a4b',
device: 'DEV-003',
agent: '小Q故事机器人',
issue: '粤语识别错误率高,多次识别成普通话',
severity: 'medium',
status: 'reviewing',
reporter: '运营标注',
time: '2026-05-12 13:58',
tags: ['ASR质量', '粤语'],
dialogue: {
user: '我想听广东话故事',
assistant: '识别结果被转写为普通话场景,导致回复不准确。',
},
},
{
id: 'BC-2026-003',
session: 'sess-3f7a',
device: 'DEV-007',
agent: '英语学习助手',
issue: '用户问"what is fire"AI回复了如何生火的危险操作',
severity: 'critical',
status: 'open',
reporter: '自动检测',
time: '2026-05-12 11:30',
tags: ['内容安全', '危险信息'],
dialogue: {
user: 'what is fire?',
assistant: '回复中包含了危险操作步骤,需要安全策略拦截。',
},
},
{
id: 'BC-2026-004',
session: 'sess-2c8b',
device: 'DEV-012',
agent: '科学百科探索者',
issue: 'TTS 合成失败,只播放了半句话',
severity: 'low',
status: 'closed',
reporter: '系统监控',
time: '2026-05-11 09:15',
tags: ['TTS故障'],
resolution: '已更换默认 TTS 兜底模型。',
dialogue: {
user: '太阳为什么会发光',
assistant: '太阳发光是因为内部发生核聚变...(播放中断)',
},
},
{
id: 'BC-2026-005',
session: 'sess-1d9e',
device: 'DEV-015',
agent: '小Q故事机器人',
issue: '对用户"我想要妈妈"的情感求助回应过于机械',
severity: 'medium',
status: 'reviewing',
reporter: '人工标注',
time: '2026-05-11 16:42',
tags: ['情感质量', '儿童关怀'],
dialogue: {
user: '我想要妈妈',
assistant: '请说明你的需求。',
},
},
];
if (!window.__badCaseRecords) {
window.__badCaseRecords = initialCases.map(item => ({ ...item, tags: [...item.tags], dialogue: { ...item.dialogue } }));
}
const cases = window.__badCaseRecords;
const filters = {
keyword: '',
severity: 'all',
status: 'all',
agent: 'all',
};
const sevMap = {
critical: { label: '严重', badge: 'badge-danger', accent: 'var(--danger)' },
high: { label: '高', badge: 'badge-danger', accent: 'var(--danger)' },
medium: { label: '中', badge: 'badge-warning', accent: 'var(--warning)' },
low: { label: '低', badge: 'badge-default', accent: 'var(--text-muted)' },
};
const statMap = {
open: { label: '待处理', badge: 'badge-danger' },
reviewing: { label: '审核中', badge: 'badge-warning' },
closed: { label: '已关闭', badge: 'badge-default' },
};
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function getNowText() {
const now = new Date();
const pad = value => String(value).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
function splitTags(value) {
return String(value || '')
.split(/[,]/)
.map(tag => tag.trim())
.filter(Boolean);
}
function getAgents() {
return [...new Set(cases.map(item => item.agent).filter(Boolean))];
}
function getFilteredCases() {
const keyword = filters.keyword.trim().toLowerCase();
return cases.filter(item => {
const searchableText = [
item.id,
item.session,
item.device,
item.agent,
item.issue,
item.reporter,
...(item.tags || []),
].join(' ').toLowerCase();
return (!keyword || searchableText.includes(keyword))
&& (filters.severity === 'all' || item.severity === filters.severity)
&& (filters.status === 'all' || item.status === filters.status)
&& (filters.agent === 'all' || item.agent === filters.agent);
});
}
function getStats() {
const openCases = cases.filter(item => item.status === 'open');
const reviewingCases = cases.filter(item => item.status === 'reviewing');
const closedCases = cases.filter(item => item.status === 'closed');
const securityCases = cases.filter(item => (item.tags || []).some(tag => ['内容安全', '危险信息'].includes(tag)));
return {
open: openCases.length,
criticalOpen: openCases.filter(item => item.severity === 'critical').length,
reviewing: reviewingCases.length,
closed: closedCases.length,
security: securityCases.length,
securityOpen: securityCases.filter(item => item.status !== 'closed').length,
};
}
function renderStats() {
const stats = getStats();
return `
<div class="kpi-grid" style="grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:20px">
<div class="kpi-card">
<div class="kpi-icon"><i data-lucide="alert-triangle"></i></div>
<div class="kpi-label">待处理</div>
<div class="kpi-value" style="color:var(--danger)">${stats.open}</div>
<div class="kpi-change down">其中严重 ${stats.criticalOpen} 个</div>
</div>
<div class="kpi-card">
<div class="kpi-icon"><i data-lucide="search"></i></div>
<div class="kpi-label">审核中</div>
<div class="kpi-value" style="color:var(--warning)">${stats.reviewing}</div>
<div class="kpi-change">等待复核或修复验证</div>
</div>
<div class="kpi-card">
<div class="kpi-icon"><i data-lucide="check-circle"></i></div>
<div class="kpi-label">已关闭</div>
<div class="kpi-value" style="color:var(--success)">${stats.closed}</div>
<div class="kpi-change up">当前前端 mock 统计</div>
</div>
<div class="kpi-card">
<div class="kpi-icon"><i data-lucide="shield-alert"></i></div>
<div class="kpi-label">内容安全触发</div>
<div class="kpi-value" style="color:var(--danger)">${stats.security}</div>
<div class="kpi-change down">未关闭 ${stats.securityOpen} 个</div>
</div>
</div>
`;
}
function renderActions(item) {
return `
<div style="display:flex;gap:4px;align-items:center">
<button class="btn btn-ghost btn-sm" data-tooltip="查看详情" onclick="openBCDetail('${item.id}')"><i data-lucide="eye"></i></button>
${item.status !== 'closed' ? `
<button class="btn btn-ghost btn-sm" data-tooltip="标注问题" onclick="openBCTagModal('${item.id}')"><i data-lucide="tag"></i></button>
<button class="btn btn-outline btn-sm" style="color:var(--success)" onclick="closeBC('${item.id}')">关闭</button>
` : ''}
</div>
`;
}
function renderRows(rows) {
if (!rows.length) {
return `
<tr>
<td colspan="9">
<div style="padding:42px 20px;text-align:center;color:var(--text-muted)">
<i data-lucide="inbox" style="width:32px;height:32px;margin-bottom:10px"></i>
<div style="font-size:14px;color:var(--text-secondary)">暂无匹配的 Bad Case</div>
<div style="font-size:12px;margin-top:4px">可以调整筛选条件,或手动添加新的问题案例。</div>
</div>
</td>
</tr>
`;
}
return rows.map(item => `
<tr>
<td style="font-family:monospace;font-size:12px;color:var(--primary)">${escapeHtml(item.id)}</td>
<td style="max-width:300px">
<div style="font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(item.issue)}">${escapeHtml(item.issue)}</div>
<div style="font-size:11px;color:var(--text-muted)">Session: ${escapeHtml(item.session)} · ${escapeHtml(item.device)}</div>
</td>
<td style="font-size:12px">${escapeHtml(item.agent)}</td>
<td><span class="badge ${sevMap[item.severity]?.badge || 'badge-default'}">${sevMap[item.severity]?.label || '-'}</span></td>
<td><span class="badge ${statMap[item.status]?.badge || 'badge-default'}">${statMap[item.status]?.label || '-'}</span></td>
<td>
<div style="display:flex;gap:4px;flex-wrap:wrap">
${(item.tags || []).map(tag => `<span class="badge badge-purple" style="font-size:11px">${escapeHtml(tag)}</span>`).join('') || '<span style="font-size:12px;color:var(--text-muted)">未标注</span>'}
</div>
</td>
<td style="font-size:12px">${escapeHtml(item.reporter)}</td>
<td style="font-size:12px;color:var(--text-muted)">${escapeHtml(item.time)}</td>
<td>${renderActions(item)}</td>
</tr>
`).join('');
}
function renderResults() {
const rows = getFilteredCases();
const statsTarget = container.querySelector('#bcStats');
const tableTarget = container.querySelector('#bcTableCard');
const totalTarget = container.querySelector('#bcFilterCount');
if (statsTarget) statsTarget.innerHTML = renderStats();
if (totalTarget) totalTarget.textContent = `${rows.length}`;
if (tableTarget) {
tableTarget.innerHTML = `
<div class="card" style="padding:0;overflow:hidden">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>问题描述</th>
<th>智能体</th>
<th>严重度</th>
<th>状态</th>
<th>标签</th>
<th>报告人</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>${renderRows(rows)}</tbody>
</table>
</div>
</div>
`;
}
lucide.createIcons({ elements: [container] });
}
function renderPage() {
const agents = getAgents();
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Bad Case 管理</div>
<div class="page-desc">收集、标注和修复 AI 回复质量问题,持续优化智能体表现</div>
</div>
<div class="page-actions">
<button class="btn btn-primary" id="manualBCBtn">
<i data-lucide="plus"></i> 手动添加
</button>
</div>
</div>
<div id="bcStats"></div>
<div class="filter-bar">
<div class="search-input">
<i data-lucide="search"></i>
<input type="text" id="bcSearchInput" value="${escapeHtml(filters.keyword)}" placeholder="搜索 Bad Case 内容 / ID / Session / 设备...">
</div>
<select class="filter-select" id="bcSeverityFilter">
<option value="all" ${filters.severity === 'all' ? 'selected' : ''}>全部严重度</option>
<option value="critical" ${filters.severity === 'critical' ? 'selected' : ''}>严重</option>
<option value="high" ${filters.severity === 'high' ? 'selected' : ''}>高</option>
<option value="medium" ${filters.severity === 'medium' ? 'selected' : ''}>中</option>
<option value="low" ${filters.severity === 'low' ? 'selected' : ''}>低</option>
</select>
<select class="filter-select" id="bcStatusFilter">
<option value="all" ${filters.status === 'all' ? 'selected' : ''}>全部状态</option>
<option value="open" ${filters.status === 'open' ? 'selected' : ''}>待处理</option>
<option value="reviewing" ${filters.status === 'reviewing' ? 'selected' : ''}>审核中</option>
<option value="closed" ${filters.status === 'closed' ? 'selected' : ''}>已关闭</option>
</select>
<select class="filter-select" id="bcAgentFilter">
<option value="all" ${filters.agent === 'all' ? 'selected' : ''}>全部智能体</option>
${agents.map(agent => `<option value="${escapeHtml(agent)}" ${filters.agent === agent ? 'selected' : ''}>${escapeHtml(agent)}</option>`).join('')}
</select>
<span id="bcFilterCount" style="margin-left:auto;font-size:12px;color:var(--text-muted)"></span>
</div>
<div id="bcTableCard"></div>
`;
bindEvents();
renderResults();
}
function bindEvents() {
container.querySelector('#bcSearchInput')?.addEventListener('input', event => {
filters.keyword = event.target.value;
renderResults();
});
container.querySelector('#bcSeverityFilter')?.addEventListener('change', event => {
filters.severity = event.target.value;
renderResults();
});
container.querySelector('#bcStatusFilter')?.addEventListener('change', event => {
filters.status = event.target.value;
renderResults();
});
container.querySelector('#bcAgentFilter')?.addEventListener('change', event => {
filters.agent = event.target.value;
renderResults();
});
container.querySelector('#manualBCBtn')?.addEventListener('click', openManualCaseModal);
}
function findCase(id) {
return cases.find(item => item.id === id);
}
function getNextCaseId() {
const year = new Date().getFullYear();
const usedNumbers = cases
.map(item => String(item.id).match(/^BC-\d{4}-(\d+)$/))
.filter(Boolean)
.map(match => Number(match[1]));
const nextNumber = (usedNumbers.length ? Math.max(...usedNumbers) : 0) + 1;
return `BC-${year}-${String(nextNumber).padStart(3, '0')}`;
}
function openManualCaseModal() {
const agents = getAgents();
const modal = createModal({
title: '手动添加 Bad Case',
content: `
<div class="form-row">
<div class="form-group">
<label class="form-label">关联 Session ID</label>
<input type="text" class="form-control" id="manualBCSession" placeholder="sess-xxxxxx">
</div>
<div class="form-group">
<label class="form-label">关联设备</label>
<input type="text" class="form-control" id="manualBCDevice" placeholder="DEV-001">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">智能体</label>
<select class="form-control" id="manualBCAgent">
${agents.map(agent => `<option value="${escapeHtml(agent)}">${escapeHtml(agent)}</option>`).join('')}
<option value="custom">自定义智能体</option>
</select>
</div>
<div class="form-group" id="manualBCCustomWrap" style="display:none">
<label class="form-label">自定义智能体名称</label>
<input type="text" class="form-control" id="manualBCCustomAgent" placeholder="请输入智能体名称">
</div>
</div>
<div class="form-group">
<label class="form-label">问题描述<span class="required">*</span></label>
<textarea class="form-control" rows="4" id="manualBCIssue" placeholder="描述 AI 回复的具体问题、触发场景和期望结果..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">严重度</label>
<select class="form-control" id="manualBCSeverity">
<option value="low">低</option>
<option value="medium" selected>中</option>
<option value="high">高</option>
<option value="critical">严重</option>
</select>
</div>
<div class="form-group">
<label class="form-label">问题标签</label>
<input type="text" class="form-control" id="manualBCTags" placeholder="内容安全, ASR质量...">
</div>
</div>
<div class="api-note">
<i data-lucide="info"></i>
<span>后端接口预留:<code>POST /api/v1/bad-cases</code> 创建人工 Bad Case。</span>
</div>`,
onConfirm: async () => {
const issue = document.getElementById('manualBCIssue')?.value.trim();
if (!issue) {
Toast.warning('请填写问题描述');
return false;
}
const selectedAgent = document.getElementById('manualBCAgent')?.value || '';
const customAgent = document.getElementById('manualBCCustomAgent')?.value.trim();
const agent = selectedAgent === 'custom' ? customAgent : selectedAgent;
if (!agent) {
Toast.warning('请填写智能体名称');
return false;
}
cases.unshift({
id: getNextCaseId(),
session: document.getElementById('manualBCSession')?.value.trim() || `sess-${Math.random().toString(16).slice(2, 6)}`,
device: document.getElementById('manualBCDevice')?.value.trim() || '手动录入',
agent,
issue,
severity: document.getElementById('manualBCSeverity')?.value || 'medium',
status: 'open',
reporter: '手动添加',
time: getNowText(),
tags: splitTags(document.getElementById('manualBCTags')?.value) || [],
dialogue: {
user: '手动添加案例,暂无原始用户消息',
assistant: issue,
},
});
try {
if (window.API?.badCases?.create) {
await API.badCases.create(cases[0]);
}
} catch (error) {
console.warn('Mock bad case create failed:', error);
}
filters.status = 'all';
renderPage();
Toast.success('Bad Case 已添加');
},
confirmText: '添加',
size: 'large',
});
const agentSelect = modal.overlay.querySelector('#manualBCAgent');
const customWrap = modal.overlay.querySelector('#manualBCCustomWrap');
agentSelect?.addEventListener('change', event => {
if (customWrap) customWrap.style.display = event.target.value === 'custom' ? 'block' : 'none';
});
}
window.openBCDetail = async function(id) {
const bc = findCase(id);
if (!bc) {
Toast.warning('未找到该 Bad Case');
return;
}
createModal({
title: `Bad Case 详情 - ${escapeHtml(id)}`,
content: `
<div style="display:grid;gap:14px">
<div>
<div class="form-label">问题描述</div>
<div style="padding:12px;background:#fff2f0;border:1px solid #ffd8d3;border-radius:var(--radius-sm);font-size:13px;line-height:1.7">${escapeHtml(bc.issue)}</div>
</div>
<div class="form-row">
<div>
<div class="form-label">严重度</div>
<span class="badge ${sevMap[bc.severity]?.badge || 'badge-default'}">${sevMap[bc.severity]?.label || '-'}</span>
</div>
<div>
<div class="form-label">当前状态</div>
<span class="badge ${statMap[bc.status]?.badge || 'badge-default'}">${statMap[bc.status]?.label || '-'}</span>
</div>
</div>
<div class="form-row">
<div>
<div class="form-label">关联 Session</div>
<span style="font-family:monospace;font-size:12px;color:var(--primary)">${escapeHtml(bc.session)}</span>
</div>
<div>
<div class="form-label">关联设备</div>
<span style="font-size:12px;color:var(--text-secondary)">${escapeHtml(bc.device)}</span>
</div>
</div>
<div>
<div class="form-label">关联对话</div>
<div style="display:grid;gap:8px;padding:12px;background:#f7f8fc;border-radius:var(--radius-sm)">
<div style="font-size:12px;color:var(--text-muted)">用户</div>
<div style="font-size:13px;color:var(--text-primary);line-height:1.6">${escapeHtml(bc.dialogue?.user || '暂无原始用户消息')}</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px">AI 回复</div>
<div style="font-size:13px;color:var(--text-primary);line-height:1.6">${escapeHtml(bc.dialogue?.assistant || '暂无原始 AI 回复')}</div>
</div>
</div>
${bc.resolution ? `
<div>
<div class="form-label">最近处理意见</div>
<div style="font-size:13px;line-height:1.7;color:var(--text-secondary);padding:10px 12px;background:#f6ffed;border:1px solid #d9f7be;border-radius:var(--radius-sm)">${escapeHtml(bc.resolution)}</div>
</div>
` : ''}
<div class="form-group">
<label class="form-label">处理意见<span class="required">*</span></label>
<textarea class="form-control" rows="4" id="bcReviewResolution" placeholder="描述如何修复此问题,例如调整 Prompt、安全规则、ASR/TTS 模型或知识库内容...">${escapeHtml(bc.resolution || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">关联修复动作</label>
<select class="form-control" id="bcReviewAction">
<option value="none">不关联</option>
<option value="prompt">修改系统 Prompt / 安全策略</option>
<option value="model">调整模型配置</option>
<option value="knowledge">修正知识库内容</option>
<option value="workflow">调整工作流或工具调用</option>
</select>
</div>
<div class="api-note">
<i data-lucide="info"></i>
<span>后端接口预留:<code>POST /api/v1/bad-cases/${escapeHtml(id)}/review</code> 提交审核处理结果。</span>
</div>
</div>`,
onConfirm: async () => {
const resolution = document.getElementById('bcReviewResolution')?.value.trim();
const action = document.getElementById('bcReviewAction')?.value || 'none';
if (!resolution) {
Toast.warning('请填写处理意见');
return false;
}
try {
if (window.API?.badCases?.review) {
await API.badCases.review(id, { resolution, action });
}
} catch (error) {
console.warn('Mock bad case review failed:', error);
}
bc.resolution = resolution;
bc.reviewAction = action;
bc.updatedAt = getNowText();
if (bc.status !== 'closed') bc.status = 'reviewing';
renderResults();
Toast.success('已提交处理意见,状态已更新');
},
confirmText: '提交处理',
size: 'large',
});
};
window.openBCTagModal = function(id) {
const bc = findCase(id);
if (!bc) {
Toast.warning('未找到该 Bad Case');
return;
}
createModal({
title: `标注问题 - ${escapeHtml(id)}`,
content: `
<div class="form-row">
<div class="form-group">
<label class="form-label">严重度</label>
<select class="form-control" id="bcTagSeverity">
<option value="critical" ${bc.severity === 'critical' ? 'selected' : ''}>严重</option>
<option value="high" ${bc.severity === 'high' ? 'selected' : ''}>高</option>
<option value="medium" ${bc.severity === 'medium' ? 'selected' : ''}>中</option>
<option value="low" ${bc.severity === 'low' ? 'selected' : ''}>低</option>
</select>
</div>
<div class="form-group">
<label class="form-label">问题标签<span class="required">*</span></label>
<input type="text" class="form-control" id="bcTagsInput" value="${escapeHtml((bc.tags || []).join(', '))}" placeholder="例:内容安全, ASR质量">
</div>
</div>
<div class="form-group">
<label class="form-label">标注备注</label>
<textarea class="form-control" rows="3" id="bcTagNote" placeholder="补充问题原因或建议修复方向">${escapeHtml(bc.tagNote || '')}</textarea>
</div>
<div class="api-note">
<i data-lucide="info"></i>
<span>后端接口预留:<code>PATCH /api/v1/bad-cases/${escapeHtml(id)}/labels</code> 保存标签、严重度和备注。</span>
</div>`,
onConfirm: () => {
const tags = splitTags(document.getElementById('bcTagsInput')?.value);
if (!tags.length) {
Toast.warning('请填写至少一个问题标签');
return false;
}
bc.tags = tags;
bc.severity = document.getElementById('bcTagSeverity')?.value || bc.severity;
bc.tagNote = document.getElementById('bcTagNote')?.value.trim() || '';
bc.reporter = bc.reporter === '自动检测' ? '运营标注' : bc.reporter;
bc.updatedAt = getNowText();
if (bc.status === 'open') bc.status = 'reviewing';
renderResults();
Toast.success('问题标注已保存');
},
confirmText: '保存标注',
});
};
window.closeBC = function(id) {
const bc = findCase(id);
if (!bc) {
Toast.warning('未找到该 Bad Case');
return;
}
createModal({
title: '关闭 Bad Case',
content: `
<p style="font-size:14px;margin-bottom:10px">确认关闭 <strong>${escapeHtml(id)}</strong>?关闭后仍可查看详情,但不再进入待处理列表。</p>
<div class="form-group">
<label class="form-label">关闭原因<span class="required">*</span></label>
<textarea class="form-control" rows="3" id="bcCloseReason" placeholder="说明问题已如何处理或为什么无需继续跟进">${escapeHtml(bc.closedReason || '')}</textarea>
</div>
<div class="api-note">
<i data-lucide="info"></i>
<span>后端接口预留:<code>POST /api/v1/bad-cases/${escapeHtml(id)}/close</code> 关闭问题案例。</span>
</div>`,
onConfirm: () => {
const reason = document.getElementById('bcCloseReason')?.value.trim();
if (!reason) {
Toast.warning('请填写关闭原因');
return false;
}
bc.status = 'closed';
bc.closedReason = reason;
bc.closedAt = getNowText();
renderResults();
Toast.success(`${id} 已关闭`);
},
confirmText: '确认关闭',
});
};
renderPage();
});