From 6c3ad6388daca44e4adfd26d54e79876d7a99a04 Mon Sep 17 00:00:00 2001 From: Aashir Athar Date: Sat, 30 May 2026 21:45:58 +0500 Subject: [PATCH] feat(intelligence): add Namespace Overview (#2964) Co-authored-by: Claude Opus 4.7 Co-authored-by: Steven Enamakel --- .../NamespaceOverviewPanel.test.tsx | 57 ++++++ .../intelligence/NamespaceOverviewPanel.tsx | 186 ++++++++++++++++++ .../NamespaceOverviewTab.test.tsx | 49 +++++ .../intelligence/NamespaceOverviewTab.tsx | 49 +++++ app/src/lib/i18n/ar.ts | 18 ++ app/src/lib/i18n/bn.ts | 19 ++ app/src/lib/i18n/de.ts | 19 ++ app/src/lib/i18n/en.ts | 19 ++ app/src/lib/i18n/es.ts | 20 ++ app/src/lib/i18n/fr.ts | 19 ++ app/src/lib/i18n/hi.ts | 19 ++ app/src/lib/i18n/id.ts | 19 ++ app/src/lib/i18n/it.ts | 19 ++ app/src/lib/i18n/ko.ts | 19 ++ app/src/lib/i18n/pl.ts | 19 ++ app/src/lib/i18n/pt.ts | 19 ++ app/src/lib/i18n/ru.ts | 19 ++ app/src/lib/i18n/zh-CN.ts | 18 ++ app/src/lib/memory/namespaceOverview.test.ts | 80 ++++++++ app/src/lib/memory/namespaceOverview.ts | 108 ++++++++++ app/src/pages/Intelligence.tsx | 7 +- .../services/api/namespaceOverviewApi.test.ts | 57 ++++++ app/src/services/api/namespaceOverviewApi.ts | 26 +++ 23 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 app/src/components/intelligence/NamespaceOverviewPanel.test.tsx create mode 100644 app/src/components/intelligence/NamespaceOverviewPanel.tsx create mode 100644 app/src/components/intelligence/NamespaceOverviewTab.test.tsx create mode 100644 app/src/components/intelligence/NamespaceOverviewTab.tsx create mode 100644 app/src/lib/memory/namespaceOverview.test.ts create mode 100644 app/src/lib/memory/namespaceOverview.ts create mode 100644 app/src/services/api/namespaceOverviewApi.test.ts create mode 100644 app/src/services/api/namespaceOverviewApi.ts diff --git a/app/src/components/intelligence/NamespaceOverviewPanel.test.tsx b/app/src/components/intelligence/NamespaceOverviewPanel.test.tsx new file mode 100644 index 000000000..9d91dcb8f --- /dev/null +++ b/app/src/components/intelligence/NamespaceOverviewPanel.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import NamespaceOverviewPanel from './NamespaceOverviewPanel'; + +function rel(namespace: string | null, subject: string, object: string): GraphRelation { + return { + namespace, + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const report = computeNamespaceOverview([ + rel('work', 'A', 'B'), + rel('work', 'B', 'C'), + rel(null, 'P', 'Q'), +]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('namespace-overview-loading')).toBeInTheDocument(); + }); + + it('renders the empty state when there are no namespaces', () => { + render(); + expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument(); + }); + + it('renders an error with a working retry button', () => { + const onRetry = vi.fn(); + render(); + expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders summary tiles and the per-namespace list (un-namespaced labeled)', () => { + render(); + expect(screen.getByText('Namespaces')).toBeInTheDocument(); + expect(screen.getByText('Facts')).toBeInTheDocument(); + expect(screen.getByText('By namespace')).toBeInTheDocument(); + expect(screen.getByText('work')).toBeInTheDocument(); + // the null namespace renders with the "un-namespaced" label + expect(screen.getByText('(un-namespaced)')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/NamespaceOverviewPanel.tsx b/app/src/components/intelligence/NamespaceOverviewPanel.tsx new file mode 100644 index 000000000..a9a98a6a6 --- /dev/null +++ b/app/src/components/intelligence/NamespaceOverviewPanel.tsx @@ -0,0 +1,186 @@ +/** + * Namespace Overview — presentational view. Pure: renders per-namespace fact / + * entity counts as a ranked bar list + summary tiles. No data fetching, no + * clock, no RNG. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { NamespaceOverviewReport } from '../../lib/memory/namespaceOverview'; + +const MAX_ROWS = 50; + +interface NamespaceOverviewPanelProps { + report: NamespaceOverviewReport | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const NamespaceOverviewPanel = ({ + report, + loading, + error, + onRetry, +}: NamespaceOverviewPanelProps) => { + const { t } = useT(); + + const intro = ( +
+

{t('namespaceOverview.title')}

+

{t('namespaceOverview.intro')}

+
+ ); + + if (loading) { + return ( +
+ {intro} +
+
+ {[0, 1, 2].map(i => ( +
+ ))} +
+ {[0, 1, 2].map(i => ( +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ {intro} +
+

+ {t('namespaceOverview.errorPrefix')} {error} +

+ {onRetry && ( + + )} +
+
+ ); + } + + if (!report || report.namespaceCount === 0) { + return ( +
+ {intro} +
+

+ {t('namespaceOverview.empty')} +

+

+ {t('namespaceOverview.emptyHint')} +

+
+
+ ); + } + + const maxFacts = report.namespaces[0]?.factCount || 1; + const rows = report.namespaces.slice(0, MAX_ROWS); + const truncated = report.namespaces.length > MAX_ROWS; + + return ( +
+ {intro} + + {/* Summary tiles */} +
+ {[ + { label: t('namespaceOverview.metricNamespaces'), value: report.namespaceCount }, + { label: t('namespaceOverview.metricFacts'), value: report.totalFacts }, + { label: t('namespaceOverview.metricEntities'), value: report.totalEntities }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+ + {/* Ranked namespace list */} +
+

+ {t('namespaceOverview.heading')} +

+
    + {rows.map(stat => ( +
  • + + {stat.namespace ?? t('namespaceOverview.unnamespaced')} + +
    +
    +
    + + {stat.factCount} + + + {t('namespaceOverview.entitiesShort').replace('{count}', String(stat.entityCount))} + +
  • + ))} +
+ {truncated && ( +

+ {t('namespaceOverview.truncated') + .replace('{shown}', String(rows.length)) + .replace('{total}', String(report.namespaces.length))} +

+ )} +
+
+ ); +}; + +export default NamespaceOverviewPanel; diff --git a/app/src/components/intelligence/NamespaceOverviewTab.test.tsx b/app/src/components/intelligence/NamespaceOverviewTab.test.tsx new file mode 100644 index 000000000..c82b7da49 --- /dev/null +++ b/app/src/components/intelligence/NamespaceOverviewTab.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import NamespaceOverviewTab from './NamespaceOverviewTab'; + +const mockLoad = vi.fn(); + +vi.mock('../../services/api/namespaceOverviewApi', () => ({ + loadNamespaceOverview: (...args: unknown[]) => mockLoad(...args), +})); + +function rel(namespace: string | null, subject: string, object: string): GraphRelation { + return { + namespace, + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const report = computeNamespaceOverview([rel('work', 'A', 'B')]); + +describe('', () => { + beforeEach(() => { + mockLoad.mockReset(); + mockLoad.mockResolvedValue(report); + }); + + it('loads on mount and renders the per-namespace list', async () => { + render(); + await waitFor(() => expect(screen.getByText('By namespace')).toBeInTheDocument()); + expect(mockLoad).toHaveBeenCalledTimes(1); + }); + + it('surfaces an error when the load fails', async () => { + mockLoad.mockReset(); + mockLoad.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/NamespaceOverviewTab.tsx b/app/src/components/intelligence/NamespaceOverviewTab.tsx new file mode 100644 index 000000000..1a7adb5e3 --- /dev/null +++ b/app/src/components/intelligence/NamespaceOverviewTab.tsx @@ -0,0 +1,49 @@ +/** + * Namespace Overview tab (container). Loads the whole graph on mount and + * delegates rendering to the pure . Read-only. No + * namespace selector — this view's axis IS the namespace, so it shows them all. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { NamespaceOverviewReport } from '../../lib/memory/namespaceOverview'; +import { loadNamespaceOverview } from '../../services/api/namespaceOverviewApi'; +import NamespaceOverviewPanel from './NamespaceOverviewPanel'; + +const NamespaceOverviewTab = () => { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // Monotonic token: ignore a response if a newer load has since started. + const latestRequestId = useRef(0); + + const load = useCallback(async () => { + const requestId = (latestRequestId.current += 1); + setLoading(true); + setError(null); + try { + const next = await loadNamespaceOverview(); + if (requestId !== latestRequestId.current) return; + setReport(next); + } catch (err) { + if (requestId !== latestRequestId.current) return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (requestId === latestRequestId.current) setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return ( + void load()} + /> + ); +}; + +export default NamespaceOverviewTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 437fe33bb..f44629bed 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -275,6 +275,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'المكالمات', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'مساحات الأسماء', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'الإعدادات', 'memory.analyzeNow': 'تحليل الآن', @@ -296,6 +297,23 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'نظرة عامة على مساحات الأسماء', + 'namespaceOverview.intro': + 'كيفية توزيع معرفتك عبر السياقات — عدد الحقائق والكيانات المتميزة المسجَّلة في كل مساحة اسم.', + 'namespaceOverview.loading': 'تجميع مساحات الأسماء…', + 'namespaceOverview.errorPrefix': 'لا يمكن تحميل الرسم البياني:', + 'namespaceOverview.retry': 'إعادة المحاولة', + 'namespaceOverview.empty': 'لا يوجد رسم بياني للمعرفة بعد.', + 'namespaceOverview.emptyHint': 'عندما يسجّل المساعد حقائق عبر السياقات، ستظهر كل مساحة اسم هنا.', + 'namespaceOverview.metricNamespaces': 'مساحات الأسماء', + 'namespaceOverview.metricFacts': 'الحقائق', + 'namespaceOverview.metricEntities': 'الكيانات', + 'namespaceOverview.heading': 'حسب مساحة الاسم', + 'namespaceOverview.unnamespaced': '(بدون مساحة اسم)', + 'namespaceOverview.factsLabel': '{count} حقيقة', + 'namespaceOverview.entitiesLabel': '{count} كيان', + 'namespaceOverview.entitiesShort': '{count} كيان', + 'namespaceOverview.truncated': 'عرض أعلى {shown} من أصل {total} مساحة اسم.', 'graphCentrality.title': 'مركز المعرفة', 'graphCentrality.intro': 'ويظهر الإرسال على صور ذاكرتك مراكز الحمل - والكيانات الموصلة التي تربط المجموعات المنفصلة عن بعضها البعض، والتي لا يمكن لإحصاء الترددات الخام أن يكشف عنها.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index ec25d99ac..2e85b97c5 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -278,6 +278,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'কল', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'নেমস্পেস', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'সেটিংস', 'memory.analyzeNow': 'এখনই বিশ্লেষণ করুন', @@ -299,6 +300,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'নেমস্পেস ওভারভিউ', + 'namespaceOverview.intro': + 'প্রসঙ্গভেদে আপনার জ্ঞান কীভাবে বিতরণ করা হয়েছে — প্রতিটি নেমস্পেসে নথিভুক্ত তথ্য ও স্বতন্ত্র সত্তার সংখ্যা।', + 'namespaceOverview.loading': 'নেমস্পেস একত্রিত করা হচ্ছে…', + 'namespaceOverview.errorPrefix': 'রেখাচিত্র লোড করা যায়নি:', + 'namespaceOverview.retry': 'পুনরায় চেষ্টা করুন', + 'namespaceOverview.empty': 'এখনও কোনো জ্ঞান গ্রাফ নেই।', + 'namespaceOverview.emptyHint': + 'সহকারী প্রসঙ্গভেদে তথ্য রেকর্ড করার সাথে সাথে প্রতিটি নেমস্পেস এখানে দেখা যাবে।', + 'namespaceOverview.metricNamespaces': 'নেমস্পেস', + 'namespaceOverview.metricFacts': 'তথ্য', + 'namespaceOverview.metricEntities': 'সত্তা', + 'namespaceOverview.heading': 'নেমস্পেস অনুযায়ী', + 'namespaceOverview.unnamespaced': '(নেমস্পেসবিহীন)', + 'namespaceOverview.factsLabel': '{count}টি তথ্য', + 'namespaceOverview.entitiesLabel': '{count}টি সত্তা', + 'namespaceOverview.entitiesShort': '{count} সত্তা', + 'namespaceOverview.truncated': '{total}টির মধ্যে শীর্ষ {shown}টি নেমস্পেস দেখানো হচ্ছে।', 'graphCentrality.title': 'জ্ঞান গ্রাফ', 'graphCentrality.intro': 'আপনার মেমরি গ্রাফের উপর প্রদর্শিত পেজের ছবি- এবং সংযোগকারী সত্তার সাথে সংযুক্ত একটি সংযুক্ত চক্রের সংযোগ রয়েছে, যা একটি raw ফ্রিকোয়েন্সি গণনা প্রকাশ করতে পারে না।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4d948b779..2989539a5 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -287,6 +287,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Anrufe', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Namensräume', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Einstellungen', 'memory.analyzeNow': 'Jetzt analysieren', @@ -308,6 +309,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'Namensraum-Übersicht', + 'namespaceOverview.intro': + 'Wie sich dein Wissen über Kontexte verteilt — die Anzahl der Fakten und eindeutigen Entitäten, die in jedem Namensraum erfasst sind.', + 'namespaceOverview.loading': 'Namensräume werden aggregiert…', + 'namespaceOverview.errorPrefix': 'Das Diagramm konnte nicht geladen werden:', + 'namespaceOverview.retry': 'Wiederholen', + 'namespaceOverview.empty': 'Noch kein Wissensgraph.', + 'namespaceOverview.emptyHint': + 'Sobald der Assistent Fakten über Kontexte hinweg erfasst, erscheint hier jeder Namensraum.', + 'namespaceOverview.metricNamespaces': 'Namensräume', + 'namespaceOverview.metricFacts': 'Fakten', + 'namespaceOverview.metricEntities': 'Entitäten', + 'namespaceOverview.heading': 'Nach Namensraum', + 'namespaceOverview.unnamespaced': '(ohne Namensraum)', + 'namespaceOverview.factsLabel': '{count} Fakten', + 'namespaceOverview.entitiesLabel': '{count} Entitäten', + 'namespaceOverview.entitiesShort': '{count} Ent.', + 'namespaceOverview.truncated': 'Die obersten {shown} von {total} Namensräumen werden angezeigt.', 'graphCentrality.title': 'Zentralität des Wissensgraphen', 'graphCentrality.intro': 'Der PageRank über Ihr Speicherdiagramm zeigt die tragenden Knotenpunkte auf – und die Konnektorentitäten, die ansonsten getrennte Cluster verbinden, die eine reine Häufigkeitszählung nicht aufdecken kann.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 2ec4ef0ec..d57ee9619 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -303,9 +303,28 @@ const en: TranslationMap = { 'memory.tab.calls': 'Calls', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Namespaces', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Settings', 'memory.analyzeNow': 'Analyze Now', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', 'memoryTimeline.title': 'Memory Timeline', 'memoryTimeline.intro': 'When the assistant learned about you — facts grouped by the month they were last reinforced. Surfaces growth, bursts of activity, and quiet stretches.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 611e7eb9a..823af2043 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -287,6 +287,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Llamadas', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Espacios de nombres', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Configuración', 'memory.analyzeNow': 'Analizar ahora', @@ -308,6 +309,25 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'Resumen de espacios de nombres', + 'namespaceOverview.intro': + 'Cómo se distribuye tu conocimiento entre contextos: el número de hechos y entidades distintas registradas en cada espacio de nombres.', + 'namespaceOverview.loading': 'Agregando espacios de nombres…', + 'namespaceOverview.errorPrefix': 'No se pudo cargar el gráfico:', + 'namespaceOverview.retry': 'Reintentar', + 'namespaceOverview.empty': 'Aún no hay gráfico de conocimiento.', + 'namespaceOverview.emptyHint': + 'A medida que el asistente registra hechos entre contextos, cada espacio de nombres aparecerá aquí.', + 'namespaceOverview.metricNamespaces': 'Espacios de nombres', + 'namespaceOverview.metricFacts': 'Hechos', + 'namespaceOverview.metricEntities': 'Entidades', + 'namespaceOverview.heading': 'Por espacio de nombres', + 'namespaceOverview.unnamespaced': '(sin espacio de nombres)', + 'namespaceOverview.factsLabel': '{count} hechos', + 'namespaceOverview.entitiesLabel': '{count} entidades', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': + 'Mostrando los {shown} principales de {total} espacios de nombres.', 'graphCentrality.title': 'Centralidad del gráfico de conocimiento', 'graphCentrality.intro': 'El PageRank sobre su gráfico de memoria muestra los centros de carga y las entidades conectoras que vinculan grupos que de otro modo estarían separados, que un recuento de frecuencia sin procesar no puede revelar.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index ba863a439..3fcdcb2e8 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -286,6 +286,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Appels', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Espaces de noms', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Paramètres', 'memory.analyzeNow': 'Analyser maintenant', @@ -307,6 +308,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'Aperçu des espaces de noms', + 'namespaceOverview.intro': + 'Comment vos connaissances sont réparties entre les contextes — le nombre de faits et d’entités distinctes enregistrés dans chaque espace de noms.', + 'namespaceOverview.loading': 'Agrégation des espaces de noms…', + 'namespaceOverview.errorPrefix': 'Impossible de charger le graphique:', + 'namespaceOverview.retry': 'Réessayer', + 'namespaceOverview.empty': 'Pas encore de graphe de connaissances.', + 'namespaceOverview.emptyHint': + 'À mesure que l’assistant enregistre des faits entre les contextes, chaque espace de noms apparaîtra ici.', + 'namespaceOverview.metricNamespaces': 'Espaces de noms', + 'namespaceOverview.metricFacts': 'Faits', + 'namespaceOverview.metricEntities': 'Entités', + 'namespaceOverview.heading': 'Par espace de noms', + 'namespaceOverview.unnamespaced': '(sans espace de noms)', + 'namespaceOverview.factsLabel': '{count} faits', + 'namespaceOverview.entitiesLabel': '{count} entités', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Affichage des {shown} premiers espaces de noms sur {total}.', 'graphCentrality.title': 'Centralité du graphe de connaissances', 'graphCentrality.intro': "PageRank sur votre graphe de mémoire met en évidence les hubs porteurs de charge — et les entités connectrices qui relient des clusters autrement séparés, ce qu'un simple comptage de fréquence ne peut révéler.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index ac8a88881..4a6e13965 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -277,6 +277,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'कॉल्स', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'नेमस्पेस', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'सेटिंग्स', 'memory.analyzeNow': 'अभी एनालाइज़ करें', @@ -298,6 +299,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'नेमस्पेस अवलोकन', + 'namespaceOverview.intro': + 'आपका ज्ञान संदर्भों में कैसे वितरित है — प्रत्येक नेमस्पेस में दर्ज तथ्यों और विशिष्ट इकाइयों की संख्या।', + 'namespaceOverview.loading': 'नेमस्पेस एकत्र किए जा रहे हैं…', + 'namespaceOverview.errorPrefix': 'ग्राफ़ लोड नहीं किया जा सका:', + 'namespaceOverview.retry': 'पुनः प्रयास करें', + 'namespaceOverview.empty': 'अभी तक कोई ज्ञान ग्राफ़ नहीं है।', + 'namespaceOverview.emptyHint': + 'जैसे-जैसे सहायक संदर्भों में तथ्य दर्ज करता है, प्रत्येक नेमस्पेस यहाँ दिखाई देगा।', + 'namespaceOverview.metricNamespaces': 'नेमस्पेस', + 'namespaceOverview.metricFacts': 'तथ्य', + 'namespaceOverview.metricEntities': 'इकाइयाँ', + 'namespaceOverview.heading': 'नेमस्पेस के अनुसार', + 'namespaceOverview.unnamespaced': '(बिना नेमस्पेस)', + 'namespaceOverview.factsLabel': '{count} तथ्य', + 'namespaceOverview.entitiesLabel': '{count} इकाइयाँ', + 'namespaceOverview.entitiesShort': '{count} इका.', + 'namespaceOverview.truncated': '{total} में से शीर्ष {shown} नेमस्पेस दिखाए जा रहे हैं।', 'graphCentrality.title': 'ज्ञान ग्राफ केंद्रीयता', 'graphCentrality.intro': 'अपने मेमोरी ग्राफ़ पर पेजरैंक लोड-असर हब सतहों - और कनेक्टर इकाइयां जो अन्यथा अलग-अलग समूहों को जोड़ती हैं, जो एक कच्चे आवृत्ति गिनती प्रकट नहीं हो सकती हैं।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index e3b99b1f7..25e9306db 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -279,6 +279,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Panggilan', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Namespace', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Pengaturan', 'memory.analyzeNow': 'Analisis Sekarang', @@ -300,6 +301,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'Ikhtisar Namespace', + 'namespaceOverview.intro': + 'Bagaimana pengetahuan Anda terdistribusi di seluruh konteks — jumlah fakta dan entitas berbeda yang tercatat di setiap namespace.', + 'namespaceOverview.loading': 'Menggabungkan namespace…', + 'namespaceOverview.errorPrefix': 'Tidak dapat memuat grafiknya:', + 'namespaceOverview.retry': 'Coba lagi', + 'namespaceOverview.empty': 'Belum ada grafik pengetahuan.', + 'namespaceOverview.emptyHint': + 'Saat asisten mencatat fakta di seluruh konteks, setiap namespace akan muncul di sini.', + 'namespaceOverview.metricNamespaces': 'Namespace', + 'namespaceOverview.metricFacts': 'Fakta', + 'namespaceOverview.metricEntities': 'Entitas', + 'namespaceOverview.heading': 'Berdasarkan namespace', + 'namespaceOverview.unnamespaced': '(tanpa namespace)', + 'namespaceOverview.factsLabel': '{count} fakta', + 'namespaceOverview.entitiesLabel': '{count} entitas', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Menampilkan {shown} teratas dari {total} namespace.', 'graphCentrality.title': 'Centralitas Grafik Pengetahuan', 'graphCentrality.intro': 'PageRank melalui grafik memori Anda permukaan hub load- bantalan - dan entiti konektor yang menghubungkan lain-terpisah cluster, yang jumlah frekuensi mentah tidak dapat mengungkapkan.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index f1dfb0867..c6ca7f0b5 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -284,6 +284,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Chiamate', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Spazi dei nomi', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Impostazioni', 'memory.analyzeNow': 'Analizza ora', @@ -305,6 +306,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'Panoramica degli spazi dei nomi', + 'namespaceOverview.intro': + 'Come la tua conoscenza è distribuita tra i contesti — il numero di fatti ed entità distinte registrate in ogni spazio dei nomi.', + 'namespaceOverview.loading': 'Aggregazione degli spazi dei nomi…', + 'namespaceOverview.errorPrefix': 'Impossibile caricare il grafico:', + 'namespaceOverview.retry': 'Riprova', + 'namespaceOverview.empty': 'Ancora nessun knowledge graph.', + 'namespaceOverview.emptyHint': + 'Man mano che l’assistente registra fatti tra i contesti, ogni spazio dei nomi apparirà qui.', + 'namespaceOverview.metricNamespaces': 'Spazi dei nomi', + 'namespaceOverview.metricFacts': 'Fatti', + 'namespaceOverview.metricEntities': 'Entità', + 'namespaceOverview.heading': 'Per spazio dei nomi', + 'namespaceOverview.unnamespaced': '(senza spazio dei nomi)', + 'namespaceOverview.factsLabel': '{count} fatti', + 'namespaceOverview.entitiesLabel': '{count} entità', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Mostrando i primi {shown} di {total} spazi dei nomi.', 'graphCentrality.title': 'Centralità del Grafo della Conoscenza', 'graphCentrality.intro': 'PageRank sul tuo grafo della memoria mette in evidenza gli hub portanti — e le entità di collegamento che connettono cluster altrimenti separati, cosa che un semplice conteggio di frequenza non può rivelare.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 21c9f06f7..5a8c392e5 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -277,6 +277,7 @@ const messages: TranslationMap = { 'memory.tab.calls': '통화', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': '네임스페이스', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': '설정', 'memory.analyzeNow': '지금 분석', @@ -298,6 +299,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': '네임스페이스 개요', + 'namespaceOverview.intro': + '컨텍스트 전반에 걸쳐 지식이 어떻게 분포되어 있는지 — 각 네임스페이스에 기록된 사실과 고유 엔터티의 수입니다.', + 'namespaceOverview.loading': '네임스페이스 집계 중…', + 'namespaceOverview.errorPrefix': '그래프를 로드할 수 없습니다:', + 'namespaceOverview.retry': '다시 시도', + 'namespaceOverview.empty': '아직 지식 그래프가 없습니다.', + 'namespaceOverview.emptyHint': + '어시스턴트가 컨텍스트 전반에 걸쳐 사실을 기록하면 각 네임스페이스가 여기에 표시됩니다.', + 'namespaceOverview.metricNamespaces': '네임스페이스', + 'namespaceOverview.metricFacts': '사실', + 'namespaceOverview.metricEntities': '엔터티', + 'namespaceOverview.heading': '네임스페이스별', + 'namespaceOverview.unnamespaced': '(네임스페이스 없음)', + 'namespaceOverview.factsLabel': '사실 {count}개', + 'namespaceOverview.entitiesLabel': '엔터티 {count}개', + 'namespaceOverview.entitiesShort': '{count}개 엔터티', + 'namespaceOverview.truncated': '전체 {total}개 네임스페이스 중 상위 {shown}개를 표시합니다.', 'graphCentrality.title': '지식 그래프 중심성', 'graphCentrality.intro': '메모리 그래프에 PageRank를 적용해 핵심 허브와, 단순 빈도 계산으로는 드러나지 않는 분리된 클러스터를 연결하는 엔터티를 드러냅니다.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index cc1d88b69..97f2e423f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -282,6 +282,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Połączenia', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Przestrzenie nazw', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Ustawienia', 'memory.analyzeNow': 'Analizuj teraz', @@ -303,6 +304,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'Przegląd przestrzeni nazw', + 'namespaceOverview.intro': + 'Jak Twoja wiedza jest rozłożona w kontekstach — liczba faktów i odrębnych encji zapisanych w każdej przestrzeni nazw.', + 'namespaceOverview.loading': 'Agregowanie przestrzeni nazw…', + 'namespaceOverview.errorPrefix': 'Nie udało się załadować grafu:', + 'namespaceOverview.retry': 'Ponów', + 'namespaceOverview.empty': 'Nie ma jeszcze grafu wiedzy.', + 'namespaceOverview.emptyHint': + 'Gdy asystent zapisuje fakty w różnych kontekstach, każda przestrzeń nazw pojawi się tutaj.', + 'namespaceOverview.metricNamespaces': 'Przestrzenie nazw', + 'namespaceOverview.metricFacts': 'Fakty', + 'namespaceOverview.metricEntities': 'Encje', + 'namespaceOverview.heading': 'Według przestrzeni nazw', + 'namespaceOverview.unnamespaced': '(bez przestrzeni nazw)', + 'namespaceOverview.factsLabel': '{count} faktów', + 'namespaceOverview.entitiesLabel': '{count} encji', + 'namespaceOverview.entitiesShort': '{count} enc.', + 'namespaceOverview.truncated': 'Wyświetlanie {shown} najważniejszych z {total} przestrzeni nazw.', 'graphCentrality.title': 'Centralność grafu wiedzy', 'graphCentrality.intro': 'PageRank nad grafem pamięci pokazuje kluczowe węzły oraz encje-łączniki, które spajają oddzielne klastry, czego nie ujawnia proste zliczanie częstotliwości.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 54ad3bf25..73ba3d657 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -286,6 +286,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Chamadas', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Namespaces', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Configurações', 'memory.analyzeNow': 'Analisar Agora', @@ -307,6 +308,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'Visão geral dos namespaces', + 'namespaceOverview.intro': + 'Como o seu conhecimento está distribuído entre os contextos — o número de fatos e entidades distintas registradas em cada namespace.', + 'namespaceOverview.loading': 'Agregando namespaces…', + 'namespaceOverview.errorPrefix': 'Não foi possível carregar o gráfico:', + 'namespaceOverview.retry': 'Tentar novamente', + 'namespaceOverview.empty': 'Ainda não há gráfico de conhecimento.', + 'namespaceOverview.emptyHint': + 'À medida que o assistente registra fatos entre contextos, cada namespace aparecerá aqui.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Fatos', + 'namespaceOverview.metricEntities': 'Entidades', + 'namespaceOverview.heading': 'Por namespace', + 'namespaceOverview.unnamespaced': '(sem namespace)', + 'namespaceOverview.factsLabel': '{count} fatos', + 'namespaceOverview.entitiesLabel': '{count} entidades', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Mostrando os {shown} principais de {total} namespaces.', 'graphCentrality.title': 'Centralidade do Grafo de Conhecimento', 'graphCentrality.intro': 'O PageRank sobre seu grafo de memória revela os hubs que suportam carga — e as entidades conectoras que ligam clusters que, de outra forma, seriam separados, algo que uma contagem de frequência bruta não consegue revelar.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index a3252110c..8b1a0cb47 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -279,6 +279,7 @@ const messages: TranslationMap = { 'memory.tab.calls': 'Звонки', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Пространства имён', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': 'Настройки', 'memory.analyzeNow': 'Анализировать сейчас', @@ -300,6 +301,24 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': 'Обзор пространств имён', + 'namespaceOverview.intro': + 'Как ваши знания распределены по контекстам — количество фактов и уникальных сущностей, записанных в каждом пространстве имён.', + 'namespaceOverview.loading': 'Агрегирование пространств имён…', + 'namespaceOverview.errorPrefix': 'Не удалось загрузить график:', + 'namespaceOverview.retry': 'Повторить попытку', + 'namespaceOverview.empty': 'Графа знаний пока нет.', + 'namespaceOverview.emptyHint': + 'По мере того как ассистент записывает факты в разных контекстах, каждое пространство имён будет появляться здесь.', + 'namespaceOverview.metricNamespaces': 'Пространства имён', + 'namespaceOverview.metricFacts': 'Факты', + 'namespaceOverview.metricEntities': 'Сущности', + 'namespaceOverview.heading': 'По пространству имён', + 'namespaceOverview.unnamespaced': '(без пространства имён)', + 'namespaceOverview.factsLabel': '{count} фактов', + 'namespaceOverview.entitiesLabel': '{count} сущностей', + 'namespaceOverview.entitiesShort': '{count} сущ.', + 'namespaceOverview.truncated': 'Показаны топ-{shown} из {total} пространств имён.', 'graphCentrality.title': 'Централизованность графа знаний', 'graphCentrality.intro': 'PageRank по вашему графику памяти отображает несущие нагрузку концентраторы — и объекты-соединители, которые связывают отдельные кластеры, которые не может выявить необработанный подсчет частоты.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 2cf5f916d..40f5f354f 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -267,6 +267,7 @@ const messages: TranslationMap = { 'memory.tab.calls': '调用记录', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': '命名空间', 'memory.tab.timeline': 'Timeline', 'memory.tab.settings': '设置', 'memory.analyzeNow': '立即分析', @@ -288,6 +289,23 @@ const messages: TranslationMap = { 'memoryTimeline.busiestCaption': 'Busiest: {period} ({count})', 'memoryTimeline.undatedNote': '{count} fact(s) have no recorded date.', 'memoryTimeline.truncated': 'Showing the {shown} most recent of {total} months.', + 'namespaceOverview.title': '命名空间概览', + 'namespaceOverview.intro': + '您的知识如何在各个上下文中分布——每个命名空间中记录的事实和不同实体的数量。', + 'namespaceOverview.loading': '正在汇总命名空间…', + 'namespaceOverview.errorPrefix': '无法加载图谱:', + 'namespaceOverview.retry': '重试', + 'namespaceOverview.empty': '暂无知识图谱。', + 'namespaceOverview.emptyHint': '随着助手在各个上下文中记录事实,每个命名空间都会显示在这里。', + 'namespaceOverview.metricNamespaces': '命名空间', + 'namespaceOverview.metricFacts': '事实', + 'namespaceOverview.metricEntities': '实体', + 'namespaceOverview.heading': '按命名空间', + 'namespaceOverview.unnamespaced': '(无命名空间)', + 'namespaceOverview.factsLabel': '{count} 条事实', + 'namespaceOverview.entitiesLabel': '{count} 个实体', + 'namespaceOverview.entitiesShort': '{count} 实体', + 'namespaceOverview.truncated': '显示 {total} 个命名空间中的前 {shown} 个。', 'graphCentrality.title': '知识图谱中心性', 'graphCentrality.intro': '对你的记忆图谱运行 PageRank,可找出关键枢纽以及连接原本分离聚类的连接实体,这是单纯频次统计无法揭示的。', diff --git a/app/src/lib/memory/namespaceOverview.test.ts b/app/src/lib/memory/namespaceOverview.test.ts new file mode 100644 index 000000000..9472daf8d --- /dev/null +++ b/app/src/lib/memory/namespaceOverview.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeNamespaceOverview } from './namespaceOverview'; + +function rel(namespace: string | null, subject: string, object: string): GraphRelation { + return { + namespace, + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('computeNamespaceOverview', () => { + it('returns an empty report for no relations', () => { + expect(computeNamespaceOverview([])).toEqual({ + namespaces: [], + namespaceCount: 0, + totalFacts: 0, + totalEntities: 0, + }); + }); + + it('aggregates distinct facts and entities per namespace', () => { + const r = computeNamespaceOverview([ + rel('work', 'A', 'B'), + rel('work', 'B', 'C'), + rel('personal', 'X', 'Y'), + rel(null, 'P', 'Q'), + ]); + expect(r.namespaceCount).toBe(3); + expect(r.totalFacts).toBe(4); + expect(r.totalEntities).toBe(7); // A,B,C,X,Y,P,Q + // Sorted by factCount desc; ties by namespace asc with null last. + expect(r.namespaces).toEqual([ + { namespace: 'work', factCount: 2, entityCount: 3 }, + { namespace: 'personal', factCount: 1, entityCount: 2 }, + { namespace: null, factCount: 1, entityCount: 2 }, + ]); + }); + + it('de-duplicates repeated triples within a namespace', () => { + const r = computeNamespaceOverview([rel('work', 'A', 'B'), rel('work', 'A', 'B')]); + expect(r.namespaces[0].factCount).toBe(1); + expect(r.totalFacts).toBe(1); + }); + + it('counts a shared entity per-namespace but once globally', () => { + const r = computeNamespaceOverview([rel('work', 'A', 'B'), rel('personal', 'A', 'C')]); + const byNs = Object.fromEntries(r.namespaces.map(s => [s.namespace, s])); + expect(byNs.work.entityCount).toBe(2); // A, B + expect(byNs.personal.entityCount).toBe(2); // A, C + expect(r.totalEntities).toBe(3); // A counted once globally + }); + + it('sorts the un-namespaced (null) bucket last on a tie', () => { + const r = computeNamespaceOverview([rel(null, 'A', 'B'), rel('aaa', 'C', 'D')]); + expect(r.namespaces.map(s => s.namespace)).toEqual(['aaa', null]); + }); + + it('drops malformed relations with a non-string field', () => { + const malformed = { ...rel('work', 'A', 'B'), object: null as unknown as string }; + const r = computeNamespaceOverview([rel('work', 'A', 'B'), malformed, rel('work', 'C', 'D')]); + expect(r.totalFacts).toBe(2); + }); + + it('is invariant to relation order', () => { + const triples = [rel('work', 'A', 'B'), rel('personal', 'X', 'Y'), rel('work', 'B', 'C')]; + const forward = computeNamespaceOverview(triples); + const reversed = computeNamespaceOverview([...triples].reverse()); + expect(reversed).toEqual(forward); + }); +}); diff --git a/app/src/lib/memory/namespaceOverview.ts b/app/src/lib/memory/namespaceOverview.ts new file mode 100644 index 000000000..ffd384a6f --- /dev/null +++ b/app/src/lib/memory/namespaceOverview.ts @@ -0,0 +1,108 @@ +/** + * Namespace Overview — pure per-namespace aggregation engine. + * + * Every memory fact carries a `namespace` (e.g. "work", "personal", or null for + * un-namespaced). This is the only lens that uses the NAMESPACE as its primary + * axis: it shows how the user's knowledge is distributed across contexts — how + * many facts and distinct entities live in each — so lopsided or empty contexts + * are visible at a glance. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. Output depends only on the relations, never on insertion order. + * Facts are de-duplicated per namespace by their (subject, predicate, object) + * triple; an entity counts once per namespace it appears in. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface NamespaceStat { + namespace: string | null; // raw namespace; null means un-namespaced + factCount: number; // distinct (subject, predicate, object) triples in this namespace + entityCount: number; // distinct entities (subject or object) in this namespace +} + +export interface NamespaceOverviewReport { + namespaces: NamespaceStat[]; // sorted by factCount desc, then namespace asc (null last) + namespaceCount: number; + totalFacts: number; // distinct triples summed across namespaces + totalEntities: number; // distinct entities across the WHOLE graph (deduped globally) +} + +const EMPTY_REPORT: NamespaceOverviewReport = { + namespaces: [], + namespaceCount: 0, + totalFacts: 0, + totalEntities: 0, +}; + +/** Canonical, collision-free key for a directed triple. */ +function tripleKey(subject: string, predicate: string, object: string): string { + return JSON.stringify([subject, predicate, object]); +} + +function compareIds(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +interface Bucket { + facts: Set; // distinct triple keys + entities: Set; // distinct entity ids +} + +/** + * Compute the per-namespace overview. Pure function of `relations`. + */ +export function computeNamespaceOverview(relations: GraphRelation[]): NamespaceOverviewReport { + const buckets = new Map(); + const allEntities = new Set(); + const ensure = (ns: string | null): Bucket => { + let bucket = buckets.get(ns); + if (!bucket) { + bucket = { facts: new Set(), entities: new Set() }; + buckets.set(ns, bucket); + } + return bucket; + }; + for (const relation of relations) { + const { subject, predicate, object } = relation; + if ( + typeof subject !== 'string' || + typeof predicate !== 'string' || + typeof object !== 'string' + ) { + continue; + } + const ns = typeof relation.namespace === 'string' ? relation.namespace : null; + const bucket = ensure(ns); + bucket.facts.add(tripleKey(subject, predicate, object)); + bucket.entities.add(subject); + bucket.entities.add(object); + allEntities.add(subject); + allEntities.add(object); + } + + if (buckets.size === 0) return EMPTY_REPORT; + + const namespaces: NamespaceStat[] = [...buckets.entries()].map(([namespace, bucket]) => ({ + namespace, + factCount: bucket.facts.size, + entityCount: bucket.entities.size, + })); + + // Sort by factCount desc, then namespace asc with null (un-namespaced) last. + namespaces.sort((a, b) => { + if (b.factCount !== a.factCount) return b.factCount - a.factCount; + if (a.namespace === null) return b.namespace === null ? 0 : 1; + if (b.namespace === null) return -1; + return compareIds(a.namespace, b.namespace); + }); + + let totalFacts = 0; + for (const stat of namespaces) totalFacts += stat.factCount; + + return { + namespaces, + namespaceCount: namespaces.length, + totalFacts, + totalEntities: allEntities.size, + }; +} diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 2ea871ed3..4800a2ad2 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -10,6 +10,7 @@ import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTa import MemoryFreshnessTab from '../components/intelligence/MemoryFreshnessTab'; import MemoryTimelineTab from '../components/intelligence/MemoryTimelineTab'; import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace'; +import NamespaceOverviewTab from '../components/intelligence/NamespaceOverviewTab'; import { ToastContainer } from '../components/intelligence/Toast'; import PillTabBar from '../components/PillTabBar'; import { @@ -34,7 +35,8 @@ type IntelligenceTab = | 'associations' | 'freshness' | 'timeline' - | 'path'; + | 'path' + | 'namespaces'; export default function Intelligence() { const { t } = useT(); @@ -118,6 +120,7 @@ export default function Intelligence() { { id: 'freshness', label: t('memory.tab.freshness') }, { id: 'timeline', label: t('memory.tab.timeline') }, { id: 'path', label: t('memory.tab.path') }, + { id: 'namespaces', label: t('memory.tab.namespaces') }, ]; const activeTabDef = tabs.find(tab => tab.id === activeTab); @@ -216,6 +219,8 @@ export default function Intelligence() { {activeTab === 'timeline' && } {activeTab === 'path' && } + + {activeTab === 'namespaces' && }
diff --git a/app/src/services/api/namespaceOverviewApi.test.ts b/app/src/services/api/namespaceOverviewApi.test.ts new file mode 100644 index 000000000..a2310585d --- /dev/null +++ b/app/src/services/api/namespaceOverviewApi.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { loadNamespaceOverview, namespaceOverviewApi } from './namespaceOverviewApi'; + +const mockGraphQuery = vi.fn(); + +vi.mock('../../utils/tauriCommands/memory', () => ({ + memoryGraphQuery: (...args: unknown[]) => mockGraphQuery(...args), +})); + +function rel(namespace: string | null, subject: string, object: string): GraphRelation { + return { + namespace, + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('namespaceOverviewApi.loadNamespaceOverview', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('fetches the whole graph (no namespace arg) and returns the engine report', async () => { + const triples = [rel('work', 'A', 'B'), rel('personal', 'X', 'Y')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadNamespaceOverview(); + expect(mockGraphQuery).toHaveBeenCalledWith(); + expect(out).toEqual(computeNamespaceOverview(triples)); + }); + + it('returns an empty report when the graph is empty', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadNamespaceOverview(); + expect(out.namespaceCount).toBe(0); + }); + + it('propagates query errors', async () => { + mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable')); + await expect(loadNamespaceOverview()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('namespaceOverviewApi object', () => { + it('exposes the public surface', () => { + expect(typeof namespaceOverviewApi.loadNamespaceOverview).toBe('function'); + }); +}); diff --git a/app/src/services/api/namespaceOverviewApi.ts b/app/src/services/api/namespaceOverviewApi.ts new file mode 100644 index 000000000..7755f4b9a --- /dev/null +++ b/app/src/services/api/namespaceOverviewApi.ts @@ -0,0 +1,26 @@ +/** + * RPC facade for Namespace Overview. + * + * Adds ZERO new core surface. Reuses ONE already-shipped JSON-RPC wrapper — + * memoryGraphQuery (openhuman.memory_graph_query) — fetching ALL namespaces in + * one call (no namespace arg) and grouping by each relation's `namespace` + * field in the pure engine. Read-only — nothing is persisted. + */ +import debug from 'debug'; + +import { + computeNamespaceOverview, + type NamespaceOverviewReport, +} from '../../lib/memory/namespaceOverview'; +import { memoryGraphQuery } from '../../utils/tauriCommands/memory'; + +const log = debug('namespace-overview:api'); + +/** Fetch the whole graph and aggregate per-namespace stats. */ +export async function loadNamespaceOverview(): Promise { + const relations = await memoryGraphQuery(); + log('loadNamespaceOverview relations=%d', relations.length); + return computeNamespaceOverview(relations); +} + +export const namespaceOverviewApi = { loadNamespaceOverview };