Release dec 2025 (#15)

* Resolves issue #13

- Switched model to bge-small-en-v1.5
- Added lazy re-embedding
- Added model version tracking per vector
- Added conversion of vectors to the new model

* Add lfs support to the workflow.

* Implements importance scoring with decay + voting #6

* Resolves issue #5 by marking observations as superseeded and scheduled for deletion

* Implement pattern detection #7

* Improve injections and observations accuracy

- Session start: Recent observations for project context (recency-based)
- User prompt: Semantically relevant observations (similarity-based with threshold)

* Added two stage retrieval with bi and cross encoder #8

* Implement query expansion and reformulation #9

* Knowledge graph and relationships ( resolves #4 )

- File Overlap Detection: Detects relationships when observations modify/read the same files
- Concept Overlap Detection: Detects relationships based on shared semantic concepts
- Type Progression Detection: Infers relationships from natural observation type progressions (e.g., discovery → bugfix = "fixes")
- Temporal Proximity Detection: Detects relationships between observations in the same session within 5 minutes
- Narrative Mention Detection: Detects explicit relationship language in narratives (e.g., "fixes", "depends on", "supersedes")

* Add visualisation of the relations to the dashboard.

* fixup! Add visualisation of the relations to the dashboard.

* Update documentation with new settings and screenshots.
This commit is contained in:
2025-12-19 17:57:11 +00:00
committed by GitHub
parent 48957a6c81
commit f79782a008
69 changed files with 43967 additions and 194 deletions
+108 -2
View File
@@ -1,13 +1,15 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.6.33-3-gf38ce5c-dirty",
"version": "0ddacaa-dirty",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-mnemonic-dashboard",
"version": "v0.6.33-3-gf38ce5c-dirty",
"version": "0ddacaa-dirty",
"dependencies": {
"vis-data": "^7.1.9",
"vis-network": "^9.1.9",
"vue": "^3.5.13"
},
"devDependencies": {
@@ -81,6 +83,19 @@
"node": ">=6.9.0"
}
},
"node_modules/@egjs/hammerjs": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/hammerjs": "^2.0.36"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -924,6 +939,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hammerjs": {
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/node": {
"version": "22.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
@@ -1352,6 +1374,19 @@
"node": ">= 6"
}
},
"node_modules/component-emitter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz",
"integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1669,6 +1704,13 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/keycharm": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz",
"integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==",
"license": "(Apache-2.0 OR MIT)",
"peer": true
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2412,6 +2454,70 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"peer": true,
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vis-data": {
"version": "7.1.10",
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.10.tgz",
"integrity": "sha512-23juM9tdCaHTX5vyIQ7XBzsfZU0Hny+gSTwniLrfFcmw9DOm7pi3+h9iEBsoZMp5rX6KNqWwc1MF0fkAmWVuoQ==",
"license": "(Apache-2.0 OR MIT)",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/visjs"
},
"peerDependencies": {
"uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"vis-util": "^5.0.1"
}
},
"node_modules/vis-network": {
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.1.13.tgz",
"integrity": "sha512-HLeHd5KZS92qzO1kC59qMh1/FWAZxMUEwUWBwDMoj6RKj/Ajkrgy/heEYo0Zc8SZNQ2J+u6omvK2+a28GX1QuQ==",
"license": "(Apache-2.0 OR MIT)",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/visjs"
},
"peerDependencies": {
"@egjs/hammerjs": "^2.0.0",
"component-emitter": "^1.3.0 || ^2.0.0",
"keycharm": "^0.2.0 || ^0.3.0 || ^0.4.0",
"uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"vis-data": "^6.3.0 || ^7.0.0",
"vis-util": "^5.0.1"
}
},
"node_modules/vis-util": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-5.0.7.tgz",
"integrity": "sha512-E3L03G3+trvc/X4LXvBfih3YIHcKS2WrP0XTdZefr6W6Qi/2nNCqZfe4JFfJU6DcQLm6Gxqj2Pfl+02859oL5A==",
"license": "(Apache-2.0 OR MIT)",
"peer": true,
"engines": {
"node": ">=8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/visjs"
},
"peerDependencies": {
"@egjs/hammerjs": "^2.0.0",
"component-emitter": "^1.3.0 || ^2.0.0"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.6.33-3-gf38ce5c-dirty",
"version": "0ddacaa-dirty",
"private": true,
"type": "module",
"scripts": {
@@ -10,6 +10,8 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vis-data": "^7.1.9",
"vis-network": "^9.1.9",
"vue": "^3.5.13"
},
"devDependencies": {
+2
View File
@@ -10,6 +10,8 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vis-data": "^7.1.9",
"vis-network": "^9.1.9",
"vue": "^3.5.13"
},
"devDependencies": {
+1 -1
View File
@@ -30,7 +30,7 @@ const {
// Pass currentProject ref to useStats for project-specific retrieval stats
const { stats } = useStats(currentProject)
// Note: Timeline refresh is handled by useTimeline's SSE watcher
// Note: Feedback is handled directly in ObservationCard component
</script>
<template>
+243 -2
View File
@@ -1,17 +1,66 @@
<script setup lang="ts">
import type { ObservationFeedItem } from '@/types'
import type { ObservationFeedItem, RelationWithDetails } from '@/types'
import { TYPE_CONFIG, CONCEPT_CONFIG } from '@/types/observation'
import { RELATION_TYPE_CONFIG, DETECTION_SOURCE_CONFIG } from '@/types/relation'
import { formatRelativeTime } from '@/utils/formatters'
import { fetchObservationRelations } from '@/utils/api'
import Card from './Card.vue'
import IconBox from './IconBox.vue'
import Badge from './Badge.vue'
import { computed } from 'vue'
import RelationGraph from './RelationGraph.vue'
import { computed, ref, onMounted } from 'vue'
const props = defineProps<{
observation: ObservationFeedItem
highlight?: boolean
showFeedback?: boolean
}>()
const emit = defineEmits<{
navigateToObservation: [id: number]
}>()
// Local feedback and score state (optimistic updates)
const localFeedback = ref<number | null>(null)
const localScore = ref<number | null>(null)
const isSubmitting = ref(false)
const currentFeedback = computed(() =>
localFeedback.value !== null ? localFeedback.value : (props.observation.user_feedback || 0)
)
const currentScore = computed(() =>
localScore.value !== null ? localScore.value : (props.observation.importance_score || 1)
)
const submitFeedback = async (value: number) => {
if (isSubmitting.value) return
// Toggle off if clicking same button
const newValue = currentFeedback.value === value ? 0 : value
localFeedback.value = newValue
isSubmitting.value = true
try {
const response = await fetch(`/api/observations/${props.observation.id}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedback: newValue })
})
if (response.ok) {
const data = await response.json()
if (data.score !== undefined) {
localScore.value = data.score
}
}
} catch (error) {
console.error('Error submitting feedback:', error)
} finally {
isSubmitting.value = false
}
}
const config = computed(() => TYPE_CONFIG[props.observation.type] || TYPE_CONFIG.change)
const concepts = computed(() => {
@@ -40,6 +89,60 @@ const filesModified = computed(() => {
const hasFiles = computed(() => filesRead.value.length > 0 || filesModified.value.length > 0)
// Relations state
const relations = ref<RelationWithDetails[]>([])
const relationsLoading = ref(false)
const relationsExpanded = ref(false)
const showGraph = ref(false)
const hasRelations = computed(() => relations.value.length > 0)
const relationCount = computed(() => relations.value.length)
// Load relations on mount
const loadRelations = async () => {
relationsLoading.value = true
try {
relations.value = await fetchObservationRelations(props.observation.id)
} catch (err) {
console.error('Failed to load relations:', err)
} finally {
relationsLoading.value = false
}
}
onMounted(() => {
loadRelations()
})
// Toggle relations expansion
const toggleRelations = () => {
relationsExpanded.value = !relationsExpanded.value
}
// Open graph modal
const openGraph = (e: Event) => {
e.stopPropagation()
showGraph.value = true
}
// Handle navigation from graph
const handleNavigateTo = (id: number) => {
showGraph.value = false
emit('navigateToObservation', id)
}
// Get relation display info (whether we're source or target)
const getRelationDisplay = (rel: RelationWithDetails) => {
const isSource = rel.relation.source_id === props.observation.id
return {
type: rel.relation.relation_type,
otherTitle: isSource ? rel.target_title : rel.source_title,
otherId: isSource ? rel.relation.target_id : rel.relation.source_id,
direction: isSource ? 'outgoing' : 'incoming',
confidence: rel.relation.confidence
}
}
// Split path into project root and relative path for styling
// e.g., /Users/foo/project/src/file.go → { root: 'project', path: 'src/file.go' }
const splitPath = (path: string, components = 3) => {
@@ -140,7 +243,145 @@ const splitPath = (path: string, components = 3) => {
</div>
</div>
</div>
<!-- Relations -->
<div v-if="hasRelations || relationsLoading" class="mt-3 pt-3 border-t border-slate-700/50">
<!-- Header with count and graph button -->
<div class="flex items-center justify-between">
<button
@click="toggleRelations"
class="flex items-center gap-2 text-xs text-slate-400 hover:text-slate-300 transition-colors"
:disabled="relationsLoading"
>
<i class="fas fa-diagram-project text-cyan-500/70" />
<span v-if="relationsLoading" class="text-slate-500">
<i class="fas fa-circle-notch fa-spin mr-1" />
Loading relations...
</span>
<span v-else>
{{ relationCount }} related observation{{ relationCount !== 1 ? 's' : '' }}
</span>
<i
v-if="!relationsLoading && hasRelations"
class="fas text-[10px] transition-transform"
:class="relationsExpanded ? 'fa-chevron-up' : 'fa-chevron-down'"
/>
</button>
<!-- View Graph button -->
<button
v-if="hasRelations"
@click="openGraph"
class="flex items-center gap-1.5 px-2 py-1 text-xs text-cyan-400 hover:text-cyan-300 bg-cyan-500/10 hover:bg-cyan-500/20 rounded transition-colors"
title="View knowledge graph"
>
<i class="fas fa-project-diagram" />
<span>View Graph</span>
</button>
</div>
<!-- Expanded relations list -->
<div
v-if="relationsExpanded && hasRelations"
class="mt-2 space-y-1.5"
>
<div
v-for="rel in relations"
:key="rel.relation.id"
class="flex items-center gap-2 text-xs p-1.5 rounded bg-slate-800/30 hover:bg-slate-800/50 transition-colors group"
>
<!-- Relation type icon -->
<i
class="fas w-4 text-center"
:class="[
RELATION_TYPE_CONFIG[getRelationDisplay(rel).type]?.icon || 'fa-link',
RELATION_TYPE_CONFIG[getRelationDisplay(rel).type]?.colorClass || 'text-slate-400'
]"
:title="RELATION_TYPE_CONFIG[getRelationDisplay(rel).type]?.label"
/>
<!-- Direction arrow -->
<i
class="fas text-[10px] text-slate-600"
:class="getRelationDisplay(rel).direction === 'outgoing' ? 'fa-arrow-right' : 'fa-arrow-left'"
/>
<!-- Related observation title -->
<span
class="flex-1 truncate text-slate-300 cursor-pointer hover:text-amber-300 transition-colors"
:title="getRelationDisplay(rel).otherTitle"
@click="emit('navigateToObservation', getRelationDisplay(rel).otherId)"
>
{{ getRelationDisplay(rel).otherTitle || 'Untitled' }}
</span>
<!-- Confidence -->
<span
class="text-[10px] text-slate-500 font-mono"
:title="`${Math.round(getRelationDisplay(rel).confidence * 100)}% confidence`"
>
{{ Math.round(getRelationDisplay(rel).confidence * 100) }}%
</span>
<!-- Detection source icon -->
<i
class="fas text-[10px] text-slate-600"
:class="DETECTION_SOURCE_CONFIG[rel.relation.detection_source]?.icon || 'fa-question'"
:title="DETECTION_SOURCE_CONFIG[rel.relation.detection_source]?.label"
/>
</div>
</div>
</div>
</div>
<!-- Feedback buttons (right side) -->
<div v-if="showFeedback" class="flex flex-col items-center gap-1 ml-2 flex-shrink-0">
<button
@click="submitFeedback(1)"
:disabled="isSubmitting"
:class="[
'p-1.5 rounded-lg transition-all duration-200',
currentFeedback === 1
? 'bg-green-500/30 text-green-300 shadow-green-500/20 shadow-sm'
: 'text-slate-500 hover:text-green-400 hover:bg-green-500/10'
]"
title="Helpful"
>
<i class="fas fa-thumbs-up text-sm" />
</button>
<span
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-slate-800/50 text-slate-400 flex items-center gap-1 transition-all duration-300"
:class="{ 'text-green-400': localScore !== null && localScore > (observation.importance_score || 1), 'text-red-400': localScore !== null && localScore < (observation.importance_score || 1) }"
:title="`Importance Score: ${currentScore.toFixed(3)}\nRetrieval Count: ${observation.retrieval_count || 0}`"
>
<i class="fas fa-scale-balanced text-amber-500/60" />
{{ currentScore.toFixed(2) }}
</span>
<button
@click="submitFeedback(-1)"
:disabled="isSubmitting"
:class="[
'p-1.5 rounded-lg transition-all duration-200',
currentFeedback === -1
? 'bg-red-500/30 text-red-300 shadow-red-500/20 shadow-sm'
: 'text-slate-500 hover:text-red-400 hover:bg-red-500/10'
]"
title="Not helpful"
>
<i class="fas fa-thumbs-down text-sm" />
</button>
</div>
</div>
<!-- Relation Graph Modal -->
<RelationGraph
:observation-id="observation.id"
:observation-title="observation.title || 'Untitled'"
:show="showGraph"
@close="showGraph = false"
@navigate-to="handleNavigateTo"
/>
</Card>
</template>
+409
View File
@@ -0,0 +1,409 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { Network, type Data, type Options } from 'vis-network'
import type { RelationGraph, RelationWithDetails } from '@/types'
import { RELATION_TYPE_CONFIG, DETECTION_SOURCE_CONFIG } from '@/types/relation'
import { fetchObservationGraph } from '@/utils/api'
const props = defineProps<{
observationId: number
observationTitle: string
show: boolean
}>()
const emit = defineEmits<{
close: []
navigateTo: [id: number]
}>()
const graphContainer = ref<HTMLElement | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const graphData = ref<RelationGraph | null>(null)
const selectedRelation = ref<RelationWithDetails | null>(null)
const depth = ref(2)
let network: Network | null = null
// Node colors based on observation type
const getNodeColor = (type: string) => {
const colors: Record<string, { background: string; border: string; highlight: { background: string; border: string } }> = {
bugfix: { background: '#ef4444', border: '#dc2626', highlight: { background: '#f87171', border: '#ef4444' } },
feature: { background: '#a855f7', border: '#9333ea', highlight: { background: '#c084fc', border: '#a855f7' } },
refactor: { background: '#3b82f6', border: '#2563eb', highlight: { background: '#60a5fa', border: '#3b82f6' } },
discovery: { background: '#06b6d4', border: '#0891b2', highlight: { background: '#22d3ee', border: '#06b6d4' } },
decision: { background: '#eab308', border: '#ca8a04', highlight: { background: '#facc15', border: '#eab308' } },
change: { background: '#64748b', border: '#475569', highlight: { background: '#94a3b8', border: '#64748b' } },
}
return colors[type] || colors.change
}
// Edge colors based on relation type
const getEdgeColor = (type: string) => {
const colors: Record<string, string> = {
causes: '#f97316',
fixes: '#22c55e',
supersedes: '#a855f7',
depends_on: '#3b82f6',
relates_to: '#64748b',
evolves_from: '#06b6d4',
}
return colors[type] || '#64748b'
}
const loadGraph = async () => {
loading.value = true
error.value = null
try {
graphData.value = await fetchObservationGraph(props.observationId, depth.value)
renderGraph()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load graph'
} finally {
loading.value = false
}
}
const renderGraph = () => {
if (!graphContainer.value || !graphData.value) return
// Build nodes and edges from relations
const nodeMap = new Map<number, { id: number; label: string; type: string }>()
const edgesList: { from: number; to: number; label: string; color: string; arrows: string; relation: RelationWithDetails }[] = []
// Add center node
nodeMap.set(props.observationId, {
id: props.observationId,
label: truncateLabel(props.observationTitle),
type: 'center'
})
// Process relations
for (const rel of graphData.value.relations) {
// Add source node
if (!nodeMap.has(rel.relation.source_id)) {
nodeMap.set(rel.relation.source_id, {
id: rel.relation.source_id,
label: truncateLabel(rel.source_title),
type: rel.source_type
})
}
// Add target node
if (!nodeMap.has(rel.relation.target_id)) {
nodeMap.set(rel.relation.target_id, {
id: rel.relation.target_id,
label: truncateLabel(rel.target_title),
type: rel.target_type
})
}
// Add edge
edgesList.push({
from: rel.relation.source_id,
to: rel.relation.target_id,
label: rel.relation.relation_type.replace('_', ' '),
color: getEdgeColor(rel.relation.relation_type),
arrows: 'to',
relation: rel
})
}
// Create vis-network data using plain arrays (simpler type compatibility)
const nodes = Array.from(nodeMap.values()).map(node => ({
id: node.id,
label: node.label,
color: node.id === props.observationId
? { background: '#f59e0b', border: '#d97706', highlight: { background: '#fbbf24', border: '#f59e0b' } }
: getNodeColor(node.type),
font: { color: '#fff', size: 12 },
shape: 'box' as const,
borderWidth: node.id === props.observationId ? 3 : 2,
margin: { top: 10, right: 10, bottom: 10, left: 10 },
shadow: true
}))
const edges = edgesList.map((edge, index) => ({
id: index,
from: edge.from,
to: edge.to,
label: edge.label,
color: { color: edge.color, highlight: edge.color },
font: { color: '#94a3b8', size: 10, strokeWidth: 0 },
arrows: edge.arrows,
width: 2,
smooth: { enabled: true, type: 'curvedCW' as const, roundness: 0.2 }
}))
// Cleanup existing network
if (network) {
network.destroy()
}
// Create network data
const data: Data = { nodes, edges }
const options: Options = {
physics: {
enabled: true,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -50,
centralGravity: 0.01,
springLength: 150,
springConstant: 0.08
},
stabilization: { iterations: 100 }
},
interaction: {
hover: true,
tooltipDelay: 200,
zoomView: true,
dragView: true
},
layout: {
improvedLayout: true
}
}
// Create network
network = new Network(graphContainer.value, data, options)
// Handle edge click to show details
network.on('selectEdge', (params: { edges: (string | number)[] }) => {
if (params.edges.length > 0) {
const edgeId = params.edges[0] as number
selectedRelation.value = edgesList[edgeId]?.relation || null
}
})
// Handle node double-click to navigate
network.on('doubleClick', (params: { nodes: (string | number)[] }) => {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0] as number
if (nodeId !== props.observationId) {
emit('navigateTo', nodeId)
}
}
})
// Clear selection when clicking background
network.on('click', (params: { nodes: (string | number)[]; edges: (string | number)[] }) => {
if (params.nodes.length === 0 && params.edges.length === 0) {
selectedRelation.value = null
}
})
}
const truncateLabel = (label: string, maxLen = 30) => {
if (label.length <= maxLen) return label
return label.substring(0, maxLen - 3) + '...'
}
const relationCount = computed(() => graphData.value?.relations.length || 0)
const closeModal = () => {
emit('close')
}
// Watch for show prop changes
watch(() => props.show, (newVal) => {
if (newVal) {
loadGraph()
} else {
selectedRelation.value = null
if (network) {
network.destroy()
network = null
}
}
})
// Watch for depth changes
watch(depth, () => {
if (props.show) {
loadGraph()
}
})
onMounted(() => {
if (props.show) {
loadGraph()
}
})
onUnmounted(() => {
if (network) {
network.destroy()
network = null
}
})
</script>
<template>
<!-- Modal backdrop -->
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" @click="closeModal" />
<!-- Modal content -->
<div class="relative bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-slate-700">
<div class="flex items-center gap-3">
<div class="p-2 rounded-lg bg-amber-500/20">
<i class="fas fa-diagram-project text-amber-400" />
</div>
<div>
<h2 class="text-lg font-semibold text-amber-100">Knowledge Graph</h2>
<p class="text-sm text-slate-400">
{{ relationCount }} relation{{ relationCount !== 1 ? 's' : '' }} for "{{ truncateLabel(observationTitle, 50) }}"
</p>
</div>
</div>
<!-- Controls -->
<div class="flex items-center gap-4">
<!-- Depth selector -->
<div class="flex items-center gap-2">
<label class="text-xs text-slate-400">Depth:</label>
<select
v-model="depth"
class="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-sm text-slate-200 focus:outline-none focus:border-amber-500"
>
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
</select>
</div>
<!-- Close button -->
<button
@click="closeModal"
class="p-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
>
<i class="fas fa-times" />
</button>
</div>
</div>
<!-- Graph container -->
<div class="relative" style="height: 60vh; min-height: 500px;">
<!-- Loading state -->
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-slate-900/50">
<div class="flex items-center gap-3 text-amber-400">
<i class="fas fa-circle-notch fa-spin text-xl" />
<span>Loading graph...</span>
</div>
</div>
<!-- Error state -->
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-3xl text-red-400 mb-2" />
<p class="text-red-300">{{ error }}</p>
<button
@click="loadGraph"
class="mt-3 px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm text-slate-200 transition-colors"
>
Retry
</button>
</div>
</div>
<!-- Empty state -->
<div v-else-if="graphData && graphData.relations.length === 0" class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<i class="fas fa-diagram-project text-4xl text-slate-600 mb-3" />
<p class="text-slate-400">No relations found for this observation</p>
<p class="text-sm text-slate-500 mt-1">Relations are detected automatically when observations share files, concepts, or patterns</p>
</div>
</div>
<!-- Graph -->
<div ref="graphContainer" class="absolute inset-0" />
</div>
<!-- Relation details panel -->
<div v-if="selectedRelation" class="border-t border-slate-700 p-4 bg-slate-800/50">
<div class="flex items-start gap-4">
<!-- Relation type icon -->
<div
class="p-3 rounded-lg"
:class="RELATION_TYPE_CONFIG[selectedRelation.relation.relation_type]?.bgClass"
>
<i
class="fas"
:class="[
RELATION_TYPE_CONFIG[selectedRelation.relation.relation_type]?.icon,
RELATION_TYPE_CONFIG[selectedRelation.relation.relation_type]?.colorClass
]"
/>
</div>
<!-- Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium" :class="RELATION_TYPE_CONFIG[selectedRelation.relation.relation_type]?.colorClass">
{{ RELATION_TYPE_CONFIG[selectedRelation.relation.relation_type]?.label }}
</span>
<span class="text-xs text-slate-500">
({{ Math.round(selectedRelation.relation.confidence * 100) }}% confidence)
</span>
</div>
<div class="flex items-center gap-2 text-sm text-slate-300 mb-2">
<span class="font-mono text-amber-400">{{ truncateLabel(selectedRelation.source_title, 40) }}</span>
<i class="fas fa-arrow-right text-slate-500 text-xs" />
<span class="font-mono text-amber-400">{{ truncateLabel(selectedRelation.target_title, 40) }}</span>
</div>
<div class="flex items-center gap-4 text-xs text-slate-500">
<span class="flex items-center gap-1">
<i :class="['fas', DETECTION_SOURCE_CONFIG[selectedRelation.relation.detection_source]?.icon]" />
{{ DETECTION_SOURCE_CONFIG[selectedRelation.relation.detection_source]?.label }}
</span>
<span v-if="selectedRelation.relation.reason">
{{ selectedRelation.relation.reason }}
</span>
</div>
</div>
</div>
</div>
<!-- Legend -->
<div class="border-t border-slate-700 p-3 bg-slate-800/30">
<div class="flex flex-wrap items-center justify-center gap-4 text-xs text-slate-400">
<span class="font-medium text-slate-300">Legend:</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-amber-500" /> Center
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-red-500" /> Bugfix
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-purple-500" /> Feature
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-blue-500" /> Refactor
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-cyan-500" /> Discovery
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-yellow-500" /> Decision
</span>
<span class="text-slate-500">|</span>
<span>Double-click node to navigate</span>
</div>
</div>
</div>
</div>
</Teleport>
</template>
+1
View File
@@ -31,6 +31,7 @@ defineProps<{
v-if="item.itemType === 'observation'"
:observation="item"
:highlight="index === 0"
:show-feedback="true"
/>
<PromptCard
v-else-if="item.itemType === 'prompt'"
+1
View File
@@ -2,3 +2,4 @@ export * from './observation'
export * from './prompt'
export * from './summary'
export * from './api'
export * from './relation'
+6
View File
@@ -30,6 +30,12 @@ export interface Observation {
created_at: string
created_at_epoch: number
is_stale?: boolean
// Importance scoring fields
importance_score: number
user_feedback: number // -1 (thumbs down), 0 (neutral), 1 (thumbs up)
retrieval_count: number
last_retrieved_at_epoch?: number
score_updated_at_epoch?: number
}
export const OBSERVATION_TYPES: ObservationType[] = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change']
+90
View File
@@ -0,0 +1,90 @@
export type RelationType = 'causes' | 'fixes' | 'supersedes' | 'depends_on' | 'relates_to' | 'evolves_from'
export type DetectionSource = 'file_overlap' | 'embedding_similarity' | 'temporal_proximity' | 'narrative_mention' | 'concept_overlap' | 'type_progression'
export interface ObservationRelation {
id: number
source_id: number
target_id: number
relation_type: RelationType
confidence: number
detection_source: DetectionSource
reason: string
created_at: string
created_at_epoch: number
}
export interface RelationWithDetails {
relation: ObservationRelation
source_title: string
target_title: string
source_type: string
target_type: string
}
export interface RelationGraph {
center_id: number
relations: RelationWithDetails[]
}
export interface RelationStats {
total_count: number
high_confidence: number
by_type: Record<RelationType, number>
min_confidence_used: number
}
// Configuration for relation type display
export const RELATION_TYPE_CONFIG: Record<RelationType, { icon: string; label: string; colorClass: string; bgClass: string; description: string }> = {
causes: {
icon: 'fa-arrow-right',
label: 'Causes',
colorClass: 'text-orange-300',
bgClass: 'bg-orange-500/20',
description: 'This observation caused the related issue'
},
fixes: {
icon: 'fa-wrench',
label: 'Fixes',
colorClass: 'text-green-300',
bgClass: 'bg-green-500/20',
description: 'This observation fixes the related issue'
},
supersedes: {
icon: 'fa-layer-group',
label: 'Supersedes',
colorClass: 'text-purple-300',
bgClass: 'bg-purple-500/20',
description: 'This observation replaces the older one'
},
depends_on: {
icon: 'fa-link',
label: 'Depends On',
colorClass: 'text-blue-300',
bgClass: 'bg-blue-500/20',
description: 'This observation depends on the related one'
},
relates_to: {
icon: 'fa-arrows-left-right',
label: 'Related',
colorClass: 'text-slate-300',
bgClass: 'bg-slate-500/20',
description: 'These observations are related'
},
evolves_from: {
icon: 'fa-code-branch',
label: 'Evolves From',
colorClass: 'text-cyan-300',
bgClass: 'bg-cyan-500/20',
description: 'This observation evolved from the related one'
}
}
// Configuration for detection source display
export const DETECTION_SOURCE_CONFIG: Record<DetectionSource, { icon: string; label: string }> = {
file_overlap: { icon: 'fa-file-code', label: 'Shared files' },
embedding_similarity: { icon: 'fa-brain', label: 'Semantic similarity' },
temporal_proximity: { icon: 'fa-clock', label: 'Close in time' },
narrative_mention: { icon: 'fa-quote-left', label: 'Mentioned in text' },
concept_overlap: { icon: 'fa-tags', label: 'Shared concepts' },
type_progression: { icon: 'fa-diagram-next', label: 'Natural progression' }
}
+18 -1
View File
@@ -1,4 +1,4 @@
import type { Observation, UserPrompt, SessionSummary, Stats, FeedItem, ObservationFeedItem, PromptFeedItem, SummaryFeedItem } from '@/types'
import type { Observation, UserPrompt, SessionSummary, Stats, FeedItem, ObservationFeedItem, PromptFeedItem, SummaryFeedItem, RelationWithDetails, RelationGraph, RelationStats } from '@/types'
const API_BASE = '/api'
const DEFAULT_TIMEOUT = 10000 // 10 seconds
@@ -147,3 +147,20 @@ export function combineTimeline(
return [...obsItems, ...promptItems, ...summaryItems]
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
}
// Relation API functions
export async function fetchObservationRelations(observationId: number, signal?: AbortSignal): Promise<RelationWithDetails[]> {
return fetchWithRetry<RelationWithDetails[]>(`${API_BASE}/observations/${observationId}/relations`, { signal })
}
export async function fetchObservationGraph(observationId: number, depth: number = 2, signal?: AbortSignal): Promise<RelationGraph> {
return fetchWithRetry<RelationGraph>(`${API_BASE}/observations/${observationId}/graph?depth=${depth}`, { signal })
}
export async function fetchRelatedObservations(observationId: number, minConfidence: number = 0.4, signal?: AbortSignal): Promise<Observation[]> {
return fetchWithRetry<Observation[]>(`${API_BASE}/observations/${observationId}/related?min_confidence=${minConfidence}`, { signal })
}
export async function fetchRelationStats(signal?: AbortSignal): Promise<RelationStats> {
return fetchWithRetry<RelationStats>(`${API_BASE}/relations/stats`, { signal })
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usehealth.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/usetypes.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/timeline.vue"],"version":"5.7.3"}
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usehealth.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/usetypes.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/relation.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/relationgraph.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/timeline.vue"],"version":"5.7.3"}