/**
* 标注结果分析页面逻辑
*/
window.AnnotationAnalysis = {
currentProjectId: null,
currentStats: null,
/**
* 初始化分析页面
*/
init: async function(projectId) {
this.currentProjectId = projectId;
this.initLlmAnalysisButton();
await this.loadAnalysisData();
await this.loadCachedAnalysis();
},
/**
* 加载分析数据
*/
loadAnalysisData: async function() {
try {
const stats = await apiGet(`/analysis/projects/${this.currentProjectId}/annotation-stats`);
this.currentStats = stats;
this.renderOverview(stats);
this.renderConfigsStats(stats.configs_stats);
this.renderNotesSummary(stats.notes_summary);
} catch (error) {
console.error('加载分析数据失败:', error);
this.showError('加载分析数据失败');
}
},
/**
* 加载缓存的 LLM 分析报告
*/
loadCachedAnalysis: async function() {
try {
const currentLang = (window.i18next && window.i18next.language) || 'zh';
const lang = currentLang.startsWith('zh') ? 'zh' : 'en';
const response = await apiGet(
`/analysis/projects/${this.currentProjectId}/cached-analysis?lang=${lang}`
);
if (response) {
this.renderLlmAnalysisResult(response);
const btn = document.getElementById('generateLlmAnalysisBtn');
if (btn) {
btn.textContent = t('analysis.regenerateAnalysis') || '重新生成分析';
}
}
} catch (error) {
// 404 means no cached analysis, which is normal
if (error.status !== 404) {
console.warn('加载缓存分析报告失败:', error);
}
}
},
/**
* 渲染概览卡片
*/
renderOverview: function(stats) {
document.getElementById('analysisTotalDatasets').textContent = stats.total_datasets;
document.getElementById('analysisTotalItems').textContent = stats.total_items;
// 已标注的QA对:至少标注了1个配置的QA对数量
document.getElementById('analysisAnnotatedItems').textContent = stats.annotated_items_count;
// 已完整标注的QA对:完成所有配置标注的QA对数量
document.getElementById('analysisFullyAnnotatedItems').textContent = stats.fully_annotated_count;
// 完整标注率:完成所有配置标注的QA对占比
document.getElementById('analysisCompletionRate').textContent =
(stats.completion_rate * 100).toFixed(1) + '%';
},
/**
* 渲染配置统计
*/
renderConfigsStats: function(configsStats) {
const container = document.getElementById('analysisConfigsContainer');
container.innerHTML = '';
if (!configsStats || configsStats.length === 0) {
container.innerHTML = '
' + t('project.noAnnotationData') + '
';
return;
}
configsStats.forEach(configStat => {
const card = this.createConfigStatCard(configStat);
container.appendChild(card);
});
},
/**
* 创建配置统计卡片
*/
createConfigStatCard: function(configStat) {
const card = document.createElement('div');
card.className = 'config-stat-card';
const header = document.createElement('div');
header.className = 'config-stat-header';
header.innerHTML = `
${this.escapeHtml(configStat.config_name)}
${this.getTypeLabel(configStat.annotation_type)}
`;
card.appendChild(header);
const info = document.createElement('div');
info.className = 'config-stat-info';
info.innerHTML = `
${t('project.annotationCount')}: ${configStat.total_annotations}
${t('project.coverage')}: ${(configStat.coverage * 100).toFixed(1)}%
`;
card.appendChild(info);
// 渲染图表容器
const chartContainer = document.createElement('div');
chartContainer.className = 'config-chart-container';
chartContainer.id = `chart-${configStat.config_id}`;
card.appendChild(chartContainer);
// 根据类型渲染不同的图表
this.renderChart(chartContainer, configStat);
return card;
},
/**
* 渲染图表
*/
renderChart: function(container, configStat) {
const stats = configStat.stats;
switch (configStat.annotation_type) {
case 'score':
this.renderScoreChart(container, stats);
break;
case 'single_choice':
case 'multi_choice':
this.renderChoiceChart(container, stats);
break;
case 'category':
this.renderCategoryChart(container, stats);
break;
case 'binary':
this.renderBinaryChart(container, stats);
break;
case 'text':
this.renderTextStats(container, stats);
break;
}
},
/**
* 渲染评分图表
*/
renderScoreChart: function(container, stats) {
try {
const canvas = document.createElement('canvas');
// 获取容器的实际宽度
const containerWidth = container.offsetWidth || 600;
const width = Math.min(containerWidth, 600);
canvas.width = width;
canvas.height = 300;
canvas.style.width = width + 'px';
canvas.style.height = '300px';
canvas.style.display = 'block';
canvas.style.maxWidth = '100%';
container.appendChild(canvas);
const labels = Object.keys(stats.distribution).sort();
const data = labels.map(key => stats.distribution[key]);
if (typeof Chart === 'undefined') {
container.innerHTML += '' + t('messages.chartLibLoadFailed') + '
';
return;
}
new Chart(canvas, {
type: 'bar',
data: {
labels: labels.map(l => l + t('config.scoreUnit') || '分'),
datasets: [{
label: t('common.count') || '数量',
data: data,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: `${t('project.averageScore') || '平均分'}: ${stats.average.toFixed(2)} (${t('project.scoreRange') || '范围'}: ${stats.min}-${stats.max})`
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
} catch (error) {
console.error('renderScoreChart error:', error);
container.innerHTML += `${t('project.chartRenderFailed')}: ${error.message}
`;
}
},
/**
* 渲染选择题图表
*/
renderChoiceChart: function(container, stats) {
const canvas = document.createElement('canvas');
// 获取容器的实际宽度
const containerWidth = container.offsetWidth || 600;
const width = Math.min(containerWidth, 600);
canvas.width = width;
canvas.height = 300;
canvas.style.width = width + 'px';
canvas.style.height = '300px';
canvas.style.display = 'block';
canvas.style.maxWidth = '100%';
container.appendChild(canvas);
const labels = Object.keys(stats.option_distribution).map(key =>
stats.option_labels[key] || key
);
const data = Object.values(stats.option_distribution);
try {
new Chart(canvas, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: [
'rgba(255, 99, 132, 0.6)',
'rgba(54, 162, 235, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(75, 192, 192, 0.6)',
'rgba(153, 102, 255, 0.6)',
'rgba(255, 159, 64, 0.6)'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: `${t('project.totalAnnotations') || '总标注数'}: ${stats.count}`
}
}
}
});
} catch (error) {
console.error('Chart创建失败:', error);
}
},
/**
* 渲染分类图表
*/
renderCategoryChart: function(container, stats) {
const canvas = document.createElement('canvas');
const containerWidth = container.offsetWidth || 600;
const width = Math.min(containerWidth, 600);
canvas.width = width;
canvas.height = 300;
canvas.style.width = width + 'px';
canvas.style.height = '300px';
canvas.style.display = 'block';
canvas.style.maxWidth = '100%';
container.appendChild(canvas);
const labels = Object.keys(stats.category_distribution);
const data = Object.values(stats.category_distribution);
new Chart(canvas, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: [
'rgba(255, 99, 132, 0.6)',
'rgba(54, 162, 235, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(75, 192, 192, 0.6)',
'rgba(153, 102, 255, 0.6)'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: `${t('project.totalAnnotations') || '总标注数'}: ${stats.count}`
}
}
}
});
},
/**
* 渲染二元标注图表
*/
renderBinaryChart: function(container, stats) {
const canvas = document.createElement('canvas');
const containerWidth = container.offsetWidth || 600;
const width = Math.min(containerWidth, 600);
canvas.width = width;
canvas.height = 300;
canvas.style.width = width + 'px';
canvas.style.height = '300px';
canvas.style.display = 'block';
canvas.style.maxWidth = '100%';
container.appendChild(canvas);
new Chart(canvas, {
type: 'pie',
data: {
labels: [t('common.yes'), t('common.no')],
datasets: [{
data: [stats.true_count, stats.false_count],
backgroundColor: [
'rgba(75, 192, 192, 0.6)',
'rgba(255, 99, 132, 0.6)'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: `${t('project.yesRatio') || `'是'占比`}: ${(stats.true_ratio * 100).toFixed(1)}%`
}
}
}
});
},
/**
* 渲染文本统计
*/
renderTextStats: function(container, stats) {
const statsDiv = document.createElement('div');
statsDiv.className = 'text-stats-container';
statsDiv.innerHTML = `
${t('project.avgLength') || '平均长度'}
${stats.avg_length.toFixed(0)} ${t('project.characters') || '字符'}
${t('project.avgWords') || '平均词数'}
${stats.avg_words.toFixed(0)} ${t('project.words') || '词'}
${t('project.maxLength') || '最长'}
${stats.max_length} ${t('project.characters') || '字符'}
${t('project.minLength') || '最短'}
${stats.min_length} ${t('project.characters') || '字符'}
`;
container.appendChild(statsDiv);
},
/**
* 渲染Notes汇总
*/
renderNotesSummary: function(notesSummary) {
const container = document.getElementById('analysisNotesContainer');
container.innerHTML = '';
if (!notesSummary || notesSummary.length === 0) {
container.innerHTML = '' + t('project.noNotes') + '
';
return;
}
notesSummary.forEach(item => {
const section = document.createElement('div');
section.className = 'notes-summary-section';
const header = document.createElement('h4');
header.textContent = `${item.config_name} (${item.count}${t('common.items') || '条'})`;
section.appendChild(header);
// 简单的关键词提取
const keywords = this.extractKeywords(item.notes);
if (keywords.length > 0) {
const keywordDiv = document.createElement('div');
keywordDiv.className = 'notes-keywords';
keywordDiv.innerHTML = `${t('project.keywords') || '关键词'}: ` + keywords.map(k =>
`${k.word}`
).join('');
section.appendChild(keywordDiv);
}
// 显示前10条notes
const notesList = document.createElement('div');
notesList.className = 'notes-list';
item.notes.slice(0, 10).forEach(note => {
const noteItem = document.createElement('div');
noteItem.className = 'note-item';
noteItem.textContent = note;
notesList.appendChild(noteItem);
});
if (item.notes.length > 10) {
const more = document.createElement('div');
more.className = 'notes-more';
more.textContent = `还有 ${item.notes.length - 10} 条...`;
notesList.appendChild(more);
}
section.appendChild(notesList);
container.appendChild(section);
});
},
/**
* 提取关键词(简单的词频统计)
*/
extractKeywords: function(notes) {
const wordCount = {};
const stopWords = ['的', '了', '是', '在', '有', '和', '与', '或', '等', '很', '也', '都', '这', '那'];
notes.forEach(note => {
// 简单的分词(按空格和常见标点)
const words = note.split(/[\s,。!?、;:""''()]+/);
words.forEach(word => {
if (word.length >= 2 && !stopWords.includes(word)) {
wordCount[word] = (wordCount[word] || 0) + 1;
}
});
});
// 排序并取前5个
return Object.entries(wordCount)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([word, count]) => ({ word, count }));
},
/**
* 获取类型标签
*/
getTypeLabel: function(type) {
const labels = {
'score': t('annotation.score'),
'category': t('annotation.category'),
'text': t('annotation.text'),
'single_choice': t('annotation.singleChoice'),
'multi_choice': t('annotation.multiChoice'),
'binary': t('annotation.binary')
};
return labels[type] || type;
},
/**
* 显示错误
*/
showError: function(message) {
const container = document.getElementById('analysisConfigsContainer');
container.innerHTML = `${message}
`;
},
/**
* 转义HTML
*/
escapeHtml: function(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// ==================== LLM 分析功能 ====================
/**
* 初始化 LLM 分析按钮
*/
initLlmAnalysisButton: function() {
const btn = document.getElementById('generateLlmAnalysisBtn');
if (btn) {
btn.addEventListener('click', () => {
this.generateLlmAnalysis();
});
}
},
/**
* 生成 LLM 分析
*/
generateLlmAnalysis: async function() {
const btn = document.getElementById('generateLlmAnalysisBtn');
const resultContainer = document.getElementById('llmAnalysisResult');
if (!btn || !resultContainer) return;
// 显示加载状态
btn.disabled = true;
btn.textContent = t('analysis.generatingAnalysis') || '正在生成分析...';
resultContainer.innerHTML = `${t('analysis.generatingAnalysis') || '正在生成分析...'}
`;
try {
// 获取当前界面语言
const currentLang = (window.i18next && window.i18next.language) || 'zh';
const lang = currentLang.startsWith('zh') ? 'zh' : 'en';
const response = await apiPost(`/analysis/projects/${this.currentProjectId}/analyze-notes?lang=${lang}`);
this.renderLlmAnalysisResult(response);
} catch (error) {
console.error('LLM 分析失败:', error);
let errorMessage = t('analysis.analysisFailed') || '分析生成失败';
if (error.data && error.data.detail) {
errorMessage += ': ' + error.data.detail;
} else if (error.message) {
errorMessage += ': ' + error.message;
}
resultContainer.innerHTML = `! ${escapeHtml(errorMessage)}
`;
} finally {
btn.disabled = false;
// 恢复按钮文字:如果有缓存报告则显示"重新生成分析",否则显示"一键生成分析"
const resultContainer = document.getElementById('llmAnalysisResult');
if (resultContainer && resultContainer.querySelector('.llm-analysis-result')) {
btn.textContent = t('analysis.regenerateAnalysis') || '重新生成分析';
} else {
btn.textContent = t('analysis.generateLlmAnalysis') || '一键生成分析';
}
}
},
/**
* 渲染 LLM 分析结果
*/
renderLlmAnalysisResult: function(response) {
const resultContainer = document.getElementById('llmAnalysisResult');
if (!resultContainer) return;
const htmlContent = this.simpleMarkdownToHtml(response.analysis);
// 保存原始 markdown 供下载用
this._lastAnalysisMarkdown = response.analysis;
this._lastAnalysisModel = response.model_name;
resultContainer.innerHTML = `
`;
// 绑定下载按钮
const downloadBtn = document.getElementById('downloadAnalysisMdBtn');
if (downloadBtn) {
downloadBtn.addEventListener('click', () => {
this.downloadAnalysisMd();
});
}
},
/**
* 下载分析结果为 Markdown 文件
*/
downloadAnalysisMd: function() {
if (!this._lastAnalysisMarkdown) return;
const blob = new Blob([this._lastAnalysisMarkdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const timestamp = new Date().toISOString().slice(0, 10);
a.href = url;
a.download = `llm-analysis-${timestamp}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
/**
* 轻量 Markdown 转 HTML 转换
*/
simpleMarkdownToHtml: function(md) {
if (!md) return '';
let html = this.escapeHtml(md);
// 代码块 (```...```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(match, lang, code) {
return '' + code + '
';
});
// 行内代码 (`...`)
html = html.replace(/`([^`]+)`/g, '$1');
// 标题 (h1-h6)
html = html.replace(/^######\s+(.+)$/gm, '$1
');
html = html.replace(/^#####\s+(.+)$/gm, '$1
');
html = html.replace(/^####\s+(.+)$/gm, '$1
');
html = html.replace(/^###\s+(.+)$/gm, '$1
');
html = html.replace(/^##\s+(.+)$/gm, '$1
');
html = html.replace(/^#\s+(.+)$/gm, '$1
');
// 粗体 (**...** 或 __...__)
html = html.replace(/\*\*(.+?)\*\*/g, '$1');
html = html.replace(/__(.+?)__/g, '$1');
// 斜体 (*...* 或 _..._)
html = html.replace(/\*(.+?)\*/g, '$1');
html = html.replace(/_(.+?)_/g, '$1');
// 引用 (> ...)
html = html.replace(/^>\s+(.+)$/gm, '$1
');
// 无序列表 (- ... 或 * ...)
html = html.replace(/^[-*]\s+(.+)$/gm, '$1');
html = html.replace(/(.*<\/li>\n?)+/g, '');
// 有序列表 (1. ...)
html = html.replace(/^\d+\.\s+(.+)$/gm, '$1');
// 段落:双换行转为段落分隔
html = html.replace(/\n\n+/g, '');
// 单换行转为
(在非标签内容中)
html = html.replace(/\n/g, '
');
// 包裹在段落中
html = '
' + html + '
';
// 清理空段落
html = html.replace(/\s*<\/p>/g, '');
html = html.replace(/
\s*()/g, '$1');
html = html.replace(/(<\/h[1-6]>)\s*<\/p>/g, '$1');
html = html.replace(/\s*(
)/g, '$1');
html = html.replace(/(<\/ul>)\s*<\/p>/g, '$1');
html = html.replace(/\s*(
)/g, '$1');
html = html.replace(/(<\/pre>)\s*<\/p>/g, '$1');
html = html.replace(/\s*(
)/g, '$1');
html = html.replace(/(<\/blockquote>)\s*<\/p>/g, '$1');
return html;
}
};