From bc07aab4bbe1efdc801a6e5009e30fec0aa0a452 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Thu, 19 Feb 2026 12:06:14 +0800 Subject: [PATCH] Phase 5: Add Timeline View feature - Add backend API endpoints for timeline data: - GET /api/v1/projects/{id}/timeline - GET /api/v1/projects/{id}/timeline/summary - GET /api/v1/entities/{id}/timeline - Add database methods for timeline queries in db_manager.py - Add frontend timeline view: - New sidebar button for timeline view - Timeline panel with date-grouped events - Visual distinction between mentions and relations - Entity filter dropdown - Statistics cards - Interactive event cards - Update STATUS.md with Phase 5 progress - Add view switching functions (switchView, switchKBTab) - Add knowledge base loading functions --- STATUS.md | 19 ++- backend/db_manager.py | 157 ++++++++++++++++++ backend/main.py | 69 ++++++++ frontend/app.js | 357 ++++++++++++++++++++++++++++++++++++++++ frontend/workbench.html | 228 ++++++++++++++++++++++++- 5 files changed, 819 insertions(+), 11 deletions(-) diff --git a/STATUS.md b/STATUS.md index bc6ff00..5ecbb19 100644 --- a/STATUS.md +++ b/STATUS.md @@ -77,6 +77,13 @@ Phase 4: Agent 助手与知识溯源 - **开发中 🚧** - [ ] 将图谱数据同步到 Neo4j - [ ] 支持复杂图查询 +### Phase 5 - 高级功能 (进行中 🚧) +- [ ] 知识推理与问答增强 +- [ ] 实体属性扩展 +- [x] 时间线视图 ✅ (2026-02-19 完成) +- [ ] 导出功能 +- [ ] 协作功能 + ## 技术债务 - 听悟 SDK fallback 到 mock 需要更好的错误处理 @@ -93,8 +100,10 @@ Phase 4: Agent 助手与知识溯源 - **开发中 🚧** ## 最近更新 ### 2026-02-19 -- 完成 Phase 4 Agent 助手功能 -- 实现知识溯源功能 -- 添加术语卡片悬停 -- 实现置信度提示 -- 更新前端 UI 和交互 +- 完成 Phase 5 时间线视图功能 + - 后端 API: `/api/v1/projects/{id}/timeline` + - 前端时间线面板,支持按日期分组显示 + - 实体提及和关系建立事件可视化 + - 实体筛选功能 + - 统计卡片展示 +- 更新 README 开发清单 diff --git a/backend/db_manager.py b/backend/db_manager.py index 553b12b..e7229d0 100644 --- a/backend/db_manager.py +++ b/backend/db_manager.py @@ -598,6 +598,163 @@ class DatabaseManager: 'recent_transcripts': [dict(t) for t in recent_transcripts], 'top_entities': [dict(e) for e in top_entities] } + + # Phase 5: Timeline operations + def get_project_timeline(self, project_id: str, entity_id: str = None, start_date: str = None, end_date: str = None) -> List[dict]: + """获取项目时间线数据 - 按时间顺序的实体提及和事件""" + conn = self.get_conn() + + # 构建查询条件 + conditions = ["t.project_id = ?"] + params = [project_id] + + if entity_id: + conditions.append("m.entity_id = ?") + params.append(entity_id) + + if start_date: + conditions.append("t.created_at >= ?") + params.append(start_date) + + if end_date: + conditions.append("t.created_at <= ?") + params.append(end_date) + + where_clause = " AND ".join(conditions) + + # 获取实体提及时间线 + mentions = conn.execute( + f"""SELECT m.*, e.name as entity_name, e.type as entity_type, e.definition, + t.filename, t.created_at as event_date, t.type as source_type + FROM entity_mentions m + JOIN entities e ON m.entity_id = e.id + JOIN transcripts t ON m.transcript_id = t.id + WHERE {where_clause} + ORDER BY t.created_at, m.start_pos""", + params + ).fetchall() + + # 获取关系创建时间线 + relation_conditions = ["r.project_id = ?"] + relation_params = [project_id] + + if start_date: + relation_conditions.append("r.created_at >= ?") + relation_params.append(start_date) + + if end_date: + relation_conditions.append("r.created_at <= ?") + relation_params.append(end_date) + + relation_where = " AND ".join(relation_conditions) + + relations = conn.execute( + f"""SELECT r.*, + s.name as source_name, t.name as target_name, + tr.filename, r.created_at as event_date + FROM entity_relations r + JOIN entities s ON r.source_entity_id = s.id + JOIN entities t ON r.target_entity_id = t.id + LEFT JOIN transcripts tr ON r.transcript_id = tr.id + WHERE {relation_where} + ORDER BY r.created_at""", + relation_params + ).fetchall() + + conn.close() + + # 合并并格式化时间线事件 + timeline_events = [] + + for m in mentions: + timeline_events.append({ + 'id': m['id'], + 'type': 'mention', + 'event_date': m['event_date'], + 'entity_id': m['entity_id'], + 'entity_name': m['entity_name'], + 'entity_type': m['entity_type'], + 'entity_definition': m['definition'], + 'text_snippet': m['text_snippet'], + 'confidence': m['confidence'], + 'source': { + 'transcript_id': m['transcript_id'], + 'filename': m['filename'], + 'type': m['source_type'] + } + }) + + for r in relations: + timeline_events.append({ + 'id': r['id'], + 'type': 'relation', + 'event_date': r['event_date'], + 'relation_type': r['relation_type'], + 'source_entity': r['source_name'], + 'target_entity': r['target_name'], + 'evidence': r['evidence'], + 'source': { + 'transcript_id': r.get('transcript_id'), + 'filename': r['filename'] + } + }) + + # 按时间排序 + timeline_events.sort(key=lambda x: x['event_date']) + + return timeline_events + + def get_entity_timeline_summary(self, project_id: str) -> dict: + """获取项目实体时间线摘要统计""" + conn = self.get_conn() + + # 按日期统计提及数量 + daily_stats = conn.execute( + """SELECT DATE(t.created_at) as date, COUNT(*) as count + FROM entity_mentions m + JOIN transcripts t ON m.transcript_id = t.id + WHERE t.project_id = ? + GROUP BY DATE(t.created_at) + ORDER BY date""", + (project_id,) + ).fetchall() + + # 按实体统计提及数量 + entity_stats = conn.execute( + """SELECT e.name, e.type, COUNT(m.id) as mention_count, + MIN(t.created_at) as first_mentioned, + MAX(t.created_at) as last_mentioned + FROM entities e + LEFT JOIN entity_mentions m ON e.id = m.entity_id + LEFT JOIN transcripts t ON m.transcript_id = t.id + WHERE e.project_id = ? + GROUP BY e.id + ORDER BY mention_count DESC + LIMIT 20""", + (project_id,) + ).fetchall() + + # 获取活跃时间段 + active_periods = conn.execute( + """SELECT + DATE(t.created_at) as date, + COUNT(DISTINCT m.entity_id) as active_entities, + COUNT(m.id) as total_mentions + FROM transcripts t + LEFT JOIN entity_mentions m ON t.id = m.transcript_id + WHERE t.project_id = ? + GROUP BY DATE(t.created_at) + ORDER BY date""", + (project_id,) + ).fetchall() + + conn.close() + + return { + 'daily_activity': [dict(d) for d in daily_stats], + 'top_entities': [dict(e) for e in entity_stats], + 'active_periods': [dict(a) for a in active_periods] + } def get_transcript_context(self, transcript_id: str, position: int, context_chars: int = 200) -> str: """获取转录文本的上下文""" diff --git a/backend/main.py b/backend/main.py index e4176dd..bb46ca0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1265,3 +1265,72 @@ async def search_entities(project_id: str, q: str): db = get_db_manager() entities = db.search_entities(project_id, q) return [{"id": e.id, "name": e.name, "type": e.type, "definition": e.definition} for e in entities] + + +# ==================== Phase 5: 时间线视图 API ==================== + +@app.get("/api/v1/projects/{project_id}/timeline") +async def get_project_timeline( + project_id: str, + entity_id: str = None, + start_date: str = None, + end_date: str = None +): + """获取项目时间线 - 按时间顺序的实体提及和关系事件""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + project = db.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + timeline = db.get_project_timeline(project_id, entity_id, start_date, end_date) + + return { + "project_id": project_id, + "events": timeline, + "total_count": len(timeline) + } + + +@app.get("/api/v1/projects/{project_id}/timeline/summary") +async def get_timeline_summary(project_id: str): + """获取项目时间线摘要统计""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + project = db.get_project(project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + summary = db.get_entity_timeline_summary(project_id) + + return { + "project_id": project_id, + "project_name": project.name, + **summary + } + + +@app.get("/api/v1/entities/{entity_id}/timeline") +async def get_entity_timeline(entity_id: str): + """获取单个实体的时间线""" + if not DB_AVAILABLE: + raise HTTPException(status_code=500, detail="Database not available") + + db = get_db_manager() + entity = db.get_entity(entity_id) + if not entity: + raise HTTPException(status_code=404, detail="Entity not found") + + timeline = db.get_project_timeline(entity.project_id, entity_id) + + return { + "entity_id": entity_id, + "entity_name": entity.name, + "entity_type": entity.type, + "events": timeline, + "total_count": len(timeline) + } diff --git a/frontend/app.js b/frontend/app.js index 7426070..ae71ff3 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -752,3 +752,360 @@ function initUpload() { } }); } + +// ==================== Phase 5: Timeline View ==================== + +async function loadTimeline() { + const container = document.getElementById('timelineContainer'); + const entityFilter = document.getElementById('timelineEntityFilter'); + + if (!container) return; + + container.innerHTML = '

加载时间线数据...

'; + + try { + // 更新实体筛选器选项 + if (entityFilter && projectEntities.length > 0) { + const currentValue = entityFilter.value; + entityFilter.innerHTML = ''; + projectEntities.forEach(ent => { + const option = document.createElement('option'); + option.value = ent.id; + option.textContent = ent.name; + entityFilter.appendChild(option); + }); + entityFilter.value = currentValue; + } + + // 构建查询参数 + const params = new URLSearchParams(); + if (entityFilter && entityFilter.value) { + params.append('entity_id', entityFilter.value); + } + + // 获取时间线数据 + const res = await fetch(`${API_BASE}/projects/${currentProject.id}/timeline?${params}`); + if (!res.ok) throw new Error('Failed to load timeline'); + + const data = await res.json(); + const events = data.events || []; + + // 更新统计 + const mentions = events.filter(e => e.type === 'mention').length; + const relations = events.filter(e => e.type === 'relation').length; + + document.getElementById('timelineTotalEvents').textContent = events.length; + document.getElementById('timelineMentions').textContent = mentions; + document.getElementById('timelineRelations').textContent = relations; + + // 渲染时间线 + renderTimeline(events); + + } catch (err) { + console.error('Load timeline failed:', err); + container.innerHTML = '

加载失败,请重试

'; + } +} + +function renderTimeline(events) { + const container = document.getElementById('timelineContainer'); + + if (events.length === 0) { + container.innerHTML = ` +
+

暂无时间线数据

+

请先上传音频或文档文件

+
+ `; + return; + } + + // 按日期分组 + const grouped = groupEventsByDate(events); + + let html = '
'; + + Object.entries(grouped).forEach(([date, dayEvents]) => { + const dateLabel = formatDateLabel(date); + + html += ` +
+
+
${dateLabel}
+
+
+
+ `; + + dayEvents.forEach(event => { + html += renderTimelineEvent(event); + }); + + html += '
'; + }); + + container.innerHTML = html; +} + +function groupEventsByDate(events) { + const grouped = {}; + + events.forEach(event => { + const date = event.event_date.split('T')[0]; + if (!grouped[date]) { + grouped[date] = []; + } + grouped[date].push(event); + }); + + return grouped; +} + +function formatDateLabel(dateStr) { + const date = new Date(dateStr); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (dateStr === today.toISOString().split('T')[0]) { + return '今天'; + } else if (dateStr === yesterday.toISOString().split('T')[0]) { + return '昨天'; + } else { + return `${date.getMonth() + 1}月${date.getDate()}日`; + } +} + +function renderTimelineEvent(event) { + if (event.type === 'mention') { + return ` +
+
+ 提及 + ${event.entity_name} + ${event.entity_type || 'OTHER'} +
+
"${event.text_snippet || ''}"
+
+ 📄 ${event.source?.filename || '未知文件'} + ${event.confidence ? `置信度: ${(event.confidence * 100).toFixed(0)}%` : ''} +
+
+ `; + } else if (event.type === 'relation') { + return ` +
+
+ 关系 + ${event.source_entity} → ${event.target_entity} +
+
+ 关系类型: ${event.relation_type} +
+ ${event.evidence ? `
"${event.evidence}"
` : ''} +
+ 📄 ${event.source?.filename || '未知文件'} +
+
+ `; + } + return ''; +} + +// ==================== View Switching ==================== + +window.switchView = function(viewName) { + // 更新侧边栏按钮状态 + document.querySelectorAll('.sidebar-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + // 隐藏所有视图 + document.getElementById('workbenchView').style.display = 'none'; + document.getElementById('knowledgeBaseView').classList.remove('show'); + document.getElementById('timelineView').classList.remove('show'); + + // 显示选中的视图 + if (viewName === 'workbench') { + document.getElementById('workbenchView').style.display = 'flex'; + document.querySelector('.sidebar-btn:nth-child(1)').classList.add('active'); + } else if (viewName === 'knowledge-base') { + document.getElementById('knowledgeBaseView').classList.add('show'); + document.querySelector('.sidebar-btn:nth-child(2)').classList.add('active'); + loadKnowledgeBase(); + } else if (viewName === 'timeline') { + document.getElementById('timelineView').classList.add('show'); + document.querySelector('.sidebar-btn:nth-child(3)').classList.add('active'); + loadTimeline(); + } +}; + +window.switchKBTab = function(tabName) { + // 更新导航项状态 + document.querySelectorAll('.kb-nav-item').forEach(item => { + item.classList.remove('active'); + }); + + // 隐藏所有部分 + document.querySelectorAll('.kb-section').forEach(section => { + section.classList.remove('active'); + }); + + // 显示选中的部分 + const tabMap = { + 'entities': { nav: 0, section: 'kbEntitiesSection' }, + 'relations': { nav: 1, section: 'kbRelationsSection' }, + 'glossary': { nav: 2, section: 'kbGlossarySection' }, + 'transcripts': { nav: 3, section: 'kbTranscriptsSection' } + }; + + const mapping = tabMap[tabName]; + if (mapping) { + document.querySelectorAll('.kb-nav-item')[mapping.nav].classList.add('active'); + document.getElementById(mapping.section).classList.add('active'); + } +}; + +async function loadKnowledgeBase() { + if (!currentProject) return; + + try { + const res = await fetch(`${API_BASE}/projects/${currentProject.id}/knowledge-base`); + if (!res.ok) throw new Error('Failed to load knowledge base'); + + const data = await res.json(); + + // 更新统计 + document.getElementById('kbEntityCount').textContent = data.stats.entity_count; + document.getElementById('kbRelationCount').textContent = data.stats.relation_count; + document.getElementById('kbTranscriptCount').textContent = data.stats.transcript_count; + document.getElementById('kbGlossaryCount').textContent = data.stats.glossary_count; + + // 渲染实体网格 + const entityGrid = document.getElementById('kbEntityGrid'); + entityGrid.innerHTML = ''; + data.entities.forEach(ent => { + const card = document.createElement('div'); + card.className = 'kb-entity-card'; + card.onclick = () => { + switchView('workbench'); + setTimeout(() => selectEntity(ent.id), 100); + }; + card.innerHTML = ` +
+ ${ent.type} + ${ent.name} +
+
${ent.definition || '暂无定义'}
+
+ 📍 ${ent.mention_count || 0} 次提及 · + ${ent.appears_in?.length || 0} 个文件 +
+ `; + entityGrid.appendChild(card); + }); + + // 渲染关系列表 + const relationsList = document.getElementById('kbRelationsList'); + relationsList.innerHTML = ''; + data.relations.forEach(rel => { + const item = document.createElement('div'); + item.className = 'kb-glossary-item'; + item.innerHTML = ` +
+ ${rel.source_name} + → ${rel.type} → + ${rel.target_name} + ${rel.evidence ? `
"${rel.evidence.substring(0, 100)}..."
` : ''} +
+ `; + relationsList.appendChild(item); + }); + + // 渲染术语表 + const glossaryList = document.getElementById('kbGlossaryList'); + glossaryList.innerHTML = ''; + data.glossary.forEach(term => { + const item = document.createElement('div'); + item.className = 'kb-glossary-item'; + item.innerHTML = ` +
+ ${term.term} + ${term.pronunciation ? `(${term.pronunciation})` : ''} + 出现 ${term.frequency} 次 +
+ + `; + glossaryList.appendChild(item); + }); + + // 渲染文件列表 + const transcriptsList = document.getElementById('kbTranscriptsList'); + transcriptsList.innerHTML = ''; + data.transcripts.forEach(t => { + const item = document.createElement('div'); + item.className = 'kb-transcript-item'; + item.innerHTML = ` +
+ ${t.type === 'audio' ? '🎵' : '📄'} + ${t.filename} +
${new Date(t.created_at).toLocaleString()}
+
+ `; + transcriptsList.appendChild(item); + }); + + } catch (err) { + console.error('Load knowledge base failed:', err); + } +} + +// ==================== Glossary Functions ==================== + +window.showAddTermModal = function() { + document.getElementById('glossaryModal').classList.add('show'); +}; + +window.hideGlossaryModal = function() { + document.getElementById('glossaryModal').classList.remove('show'); +}; + +window.saveGlossaryTerm = async function() { + const term = document.getElementById('glossaryTerm').value.trim(); + const pronunciation = document.getElementById('glossaryPronunciation').value.trim(); + + if (!term) return; + + try { + const res = await fetch(`${API_BASE}/projects/${currentProject.id}/glossary`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ term, pronunciation }) + }); + + if (res.ok) { + hideGlossaryModal(); + document.getElementById('glossaryTerm').value = ''; + document.getElementById('glossaryPronunciation').value = ''; + loadKnowledgeBase(); + } + } catch (err) { + console.error('Save glossary term failed:', err); + } +}; + +window.deleteGlossaryTerm = async function(termId) { + if (!confirm('确定要删除这个术语吗?')) return; + + try { + const res = await fetch(`${API_BASE}/glossary/${termId}`, { + method: 'DELETE' + }); + + if (res.ok) { + loadKnowledgeBase(); + } + } catch (err) { + console.error('Delete glossary term failed:', err); + } +}; diff --git a/frontend/workbench.html b/frontend/workbench.html index ca3f458..d8af73a 100644 --- a/frontend/workbench.html +++ b/frontend/workbench.html @@ -842,11 +842,183 @@ color: #888; } - /* Phase 4: Low confidence entity */ - .entity.low-confidence { - background: rgba(255, 193, 7, 0.3); - border-bottom-color: #ffc107; + /* Phase 5: Timeline View */ + .timeline-panel { + width: 100%; + height: 100%; + display: none; + flex-direction: column; + background: #0a0a0a; } + .timeline-panel.show { + display: flex; + } + .timeline-header { + padding: 16px 20px; + background: #141414; + border-bottom: 1px solid #222; + display: flex; + justify-content: space-between; + align-items: center; + } + .timeline-filters { + display: flex; + gap: 12px; + align-items: center; + } + .timeline-filter-select { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 6px; + padding: 8px 12px; + color: #e0e0e0; + font-size: 0.9rem; + } + .timeline-content { + flex: 1; + overflow-y: auto; + padding: 20px; + } + .timeline-container { + position: relative; + max-width: 900px; + margin: 0 auto; + } + .timeline-line { + position: absolute; + left: 120px; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(180deg, #00d4ff, #7b2cbf); + } + .timeline-date-group { + margin-bottom: 32px; + } + .timeline-date-header { + display: flex; + align-items: center; + margin-bottom: 16px; + position: relative; + } + .timeline-date-label { + width: 100px; + text-align: right; + padding-right: 20px; + color: #888; + font-size: 0.85rem; + font-weight: 500; + } + .timeline-date-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: #00d4ff; + position: absolute; + left: 115px; + box-shadow: 0 0 10px rgba(0, 212, 255, 0.5); + } + .timeline-events { + margin-left: 140px; + } + .timeline-event { + background: #141414; + border: 1px solid #222; + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; + transition: all 0.2s; + cursor: pointer; + } + .timeline-event:hover { + border-color: #00d4ff; + background: #1a1a1a; + } + .timeline-event-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + } + .timeline-event-type { + padding: 2px 8px; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + } + .timeline-event-type.mention { + background: #7b2cbf; + } + .timeline-event-type.relation { + background: #00d4ff; + color: #000; + } + .timeline-event-entity { + font-weight: 600; + color: #fff; + } + .timeline-event-snippet { + color: #aaa; + font-size: 0.9rem; + line-height: 1.5; + margin-bottom: 8px; + padding-left: 12px; + border-left: 2px solid #333; + } + .timeline-event-source { + font-size: 0.8rem; + color: #666; + display: flex; + align-items: center; + gap: 8px; + } + .timeline-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; + } + .timeline-stat-card { + background: #141414; + border: 1px solid #222; + border-radius: 8px; + padding: 16px; + } + .timeline-stat-title { + font-size: 0.8rem; + color: #666; + margin-bottom: 8px; + } + .timeline-stat-value { + font-size: 1.5rem; + font-weight: 600; + color: #00d4ff; + } + .timeline-empty { + text-align: center; + padding: 60px 20px; + color: #666; + } + .timeline-legend { + display: flex; + gap: 20px; + margin-top: 8px; + } + .timeline-legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: #888; + } + .timeline-legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + } + .timeline-legend-dot.mention { background: #7b2cbf; } + .timeline-legend-dot.relation { background: #00d4ff; } /* Typing indicator */ .typing-indicator { @@ -885,6 +1057,7 @@ @@ -957,8 +1130,51 @@ - -
+ +
+
+
+

📅 项目时间线

+
+
+
+ 实体提及 +
+
+
+ 关系建立 +
+
+
+
+ + +
+
+
+
+
+
总事件数
+
-
+
+
+
实体提及
+
-
+
+
+
关系建立
+
-
+
+
+
+
+

加载时间线数据...

+
+
+
+

📚 项目知识库