diff --git a/app/src/components/intelligence/KnowledgeGapsPanel.test.tsx b/app/src/components/intelligence/KnowledgeGapsPanel.test.tsx new file mode 100644 index 00000000000..92edba48fb6 --- /dev/null +++ b/app/src/components/intelligence/KnowledgeGapsPanel.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeKnowledgeGaps } from '../../lib/memory/knowledgeGaps'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import KnowledgeGapsPanel from './KnowledgeGapsPanel'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const report = computeKnowledgeGaps([rel('A', 'B'), rel('B', 'C'), rel('D', 'D')]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('knowledge-gaps-loading')).toBeInTheDocument(); + }); + + it('renders the empty state when there is no graph', () => { + 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 gap list with kind badges', () => { + render(); + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByText('Orphans')).toBeInTheDocument(); + expect(screen.getByText('Leaves')).toBeInTheDocument(); + expect(screen.getByText('Sparse entities')).toBeInTheDocument(); + expect(screen.getByText('D')).toBeInTheDocument(); // orphan + expect(screen.getByText('orphan')).toBeInTheDocument(); + expect(screen.getAllByText('leaf').length).toBeGreaterThanOrEqual(1); + }); + + it('renders the all-connected message when there are no gaps', () => { + const clean = computeKnowledgeGaps([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + render(); + expect(screen.getByText('Every entity is well-connected — no gaps found.')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/KnowledgeGapsPanel.tsx b/app/src/components/intelligence/KnowledgeGapsPanel.tsx new file mode 100644 index 00000000000..bc0429f29ed --- /dev/null +++ b/app/src/components/intelligence/KnowledgeGapsPanel.tsx @@ -0,0 +1,177 @@ +/** + * Knowledge Gaps — presentational view. Pure: renders the orphan/leaf stub list + * + summary tiles. No data fetching, no clock, no RNG. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { GapEntity, KnowledgeGapsReport } from '../../lib/memory/knowledgeGaps'; + +const MAX_ROWS = 50; + +interface KnowledgeGapsPanelProps { + report: KnowledgeGapsReport | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const pct = (fraction: number): number => Math.round(fraction * 100); + +const KIND_BADGE: Record = { + orphan: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300', + leaf: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300', +}; + +const KnowledgeGapsPanel = ({ report, loading, error, onRetry }: KnowledgeGapsPanelProps) => { + const { t } = useT(); + + const intro = ( +
+

{t('knowledgeGaps.title')}

+

{t('knowledgeGaps.intro')}

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

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

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

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

+

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

+
+
+ ); + } + + const rows = report.gaps.slice(0, MAX_ROWS); + const truncated = report.gaps.length > MAX_ROWS; + + return ( +
+ {intro} + + {/* Summary tiles */} +
+ {[ + { label: t('knowledgeGaps.metricEntities'), value: String(report.entityCount) }, + { label: t('knowledgeGaps.metricOrphans'), value: String(report.orphanCount) }, + { label: t('knowledgeGaps.metricLeaves'), value: String(report.leafCount) }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+

+ {t('knowledgeGaps.ratioCaption').replace('{ratio}', String(pct(report.gapRatio)))} +

+ + {report.gaps.length === 0 ? ( +

+ {t('knowledgeGaps.allConnected')} +

+ ) : ( +
+

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

+
    + {rows.map(gap => ( +
  • + + {gap.id} + {gap.objectOnly && ( + + {t('knowledgeGaps.objectOnly')} + + )} + + + {gap.kind === 'orphan' + ? t('knowledgeGaps.kindOrphan') + : t('knowledgeGaps.kindLeaf')} + +
  • + ))} +
+ {truncated && ( +

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

+ )} +
+ )} +
+ ); +}; + +export default KnowledgeGapsPanel; diff --git a/app/src/components/intelligence/KnowledgeGapsTab.test.tsx b/app/src/components/intelligence/KnowledgeGapsTab.test.tsx new file mode 100644 index 00000000000..8f62d4e80eb --- /dev/null +++ b/app/src/components/intelligence/KnowledgeGapsTab.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeKnowledgeGaps } from '../../lib/memory/knowledgeGaps'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import KnowledgeGapsTab from './KnowledgeGapsTab'; + +const mockLoad = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/knowledgeGapsApi', () => ({ + loadKnowledgeGaps: (...args: unknown[]) => mockLoad(...args), + loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args), +})); + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const report = computeKnowledgeGaps([rel('A', 'B'), rel('B', 'C')]); + +describe('', () => { + beforeEach(() => { + mockLoad.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoad.mockResolvedValue(report); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads on mount and renders the gap list', async () => { + render(); + await waitFor(() => expect(screen.getByText('Sparse entities')).toBeInTheDocument()); + expect(mockLoad).toHaveBeenCalledWith(undefined); + }); + + it('shows the namespace selector and re-queries on change', async () => { + mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']); + render(); + await waitFor(() => screen.getByRole('combobox')); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } }); + await waitFor(() => expect(mockLoad).toHaveBeenCalledWith('work')); + }); + + 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/KnowledgeGapsTab.tsx b/app/src/components/intelligence/KnowledgeGapsTab.tsx new file mode 100644 index 00000000000..4d25b9a42c9 --- /dev/null +++ b/app/src/components/intelligence/KnowledgeGapsTab.tsx @@ -0,0 +1,79 @@ +/** + * Knowledge Gaps tab (container). Load-on-mount + namespace selector; delegates + * rendering to the pure . Read-only. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { KnowledgeGapsReport } from '../../lib/memory/knowledgeGaps'; +import { loadKnowledgeGaps, loadNamespaces } from '../../services/api/knowledgeGapsApi'; +import KnowledgeGapsPanel from './KnowledgeGapsPanel'; + +const KnowledgeGapsTab = () => { + const { t } = useT(); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [namespaces, setNamespaces] = useState([]); + const [namespace, setNamespace] = useState(''); + // Monotonic token: ignore a response if a newer load has since started. + const latestRequestId = useRef(0); + + const load = useCallback(async (ns: string) => { + const requestId = (latestRequestId.current += 1); + setLoading(true); + setError(null); + try { + const next = await loadKnowledgeGaps(ns || undefined); + 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(() => { + loadNamespaces() + .then(setNamespaces) + .catch(() => setNamespaces([])); + void load(''); + }, [load]); + + const handleNamespace = (next: string): void => { + setNamespace(next); + void load(next); + }; + + return ( +
+ {namespaces.length > 0 && ( + + )} + + void load(namespace)} + /> +
+ ); +}; + +export default KnowledgeGapsTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 07abec613ab..3fad0ac6d1b 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4370,6 +4370,27 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'رفض التخزين المحلي', 'pages.settings.account.security': 'الأمان', 'pages.settings.account.securityDesc': 'وضع تخزين الأسرار وحالة سلسلة المفاتيح', + 'knowledgeGaps.allConnected': 'كل كيان موصول جيداً — لم يُعثر على فجوات.', + 'knowledgeGaps.empty': 'لا يوجد رسم معرفي بعد.', + 'knowledgeGaps.emptyHint': 'كلما سجّل المساعد حقائق، ستظهر هنا الكيانات قليلة الاتصال.', + 'knowledgeGaps.errorPrefix': 'تعذّر تحميل الرسم البياني:', + 'knowledgeGaps.heading': 'كيانات شحيحة', + 'knowledgeGaps.intro': + 'عكس المركزية: كيانات سجّل المساعد عنها القليل جداً — الأيتام (بلا اتصالات) والأوراق (اتصال واحد) — أفضل المرشحين للتعلم أكثر عنهم.', + 'knowledgeGaps.kindLeaf': 'ورقة', + 'knowledgeGaps.kindOrphan': 'يتيم', + 'knowledgeGaps.loading': 'البحث عن الفجوات…', + 'knowledgeGaps.metricEntities': 'الكيانات', + 'knowledgeGaps.metricLeaves': 'أوراق', + 'knowledgeGaps.metricOrphans': 'أيتام', + 'knowledgeGaps.namespaceAll': 'كل مساحات الأسماء', + 'knowledgeGaps.namespaceLabel': 'مساحة الأسماء', + 'knowledgeGaps.objectOnly': 'مذكور، لم يوصف أبداً', + 'knowledgeGaps.ratioCaption': '{ratio}% من الكيانات قليلة الاتصال', + 'knowledgeGaps.retry': 'إعادة المحاولة', + 'knowledgeGaps.title': 'فجوات المعرفة', + 'knowledgeGaps.truncated': 'عرض {shown} من أصل {total} كيان شحيح.', + 'memory.tab.gaps': 'الفجوات', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 528f58a7bca..5124212c0ab 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4448,6 +4448,27 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'স্থানীয় সঞ্চয়স্থান প্রত্যাখ্যান করুন', 'pages.settings.account.security': 'নিরাপত্তা', 'pages.settings.account.securityDesc': 'গোপনীয়তা সঞ্চয়স্থান মোড এবং কিচেন অবস্থা', + 'knowledgeGaps.allConnected': 'প্রতিটি সত্তা ভালভাবে সংযুক্ত — কোন ফাঁক পাওয়া যায়নি।', + 'knowledgeGaps.empty': 'এখনও কোনো জ্ঞান গ্রাফ নেই।', + 'knowledgeGaps.emptyHint': 'সহকারী যখন তথ্য রেকর্ড করে, কম-সংযুক্ত সত্তাগুলি এখানে উঠে আসবে।', + 'knowledgeGaps.errorPrefix': 'গ্রাফ লোড করা যায়নি:', + 'knowledgeGaps.heading': 'বিরল সত্তা', + 'knowledgeGaps.intro': + 'কেন্দ্রীয়তার বিপরীত: যেসব সত্তা সম্পর্কে সহকারী খুব কমই কিছু রেকর্ড করেছে — অনাথ (কোনো সংযোগ নেই) এবং পাতা (একটি সংযোগ) — সম্পর্কে আরও জানার সেরা প্রার্থী।', + 'knowledgeGaps.kindLeaf': 'পাতা', + 'knowledgeGaps.kindOrphan': 'অনাথ', + 'knowledgeGaps.loading': 'ফাঁক খোঁজা হচ্ছে…', + 'knowledgeGaps.metricEntities': 'সত্তা', + 'knowledgeGaps.metricLeaves': 'পাতা', + 'knowledgeGaps.metricOrphans': 'অনাথ', + 'knowledgeGaps.namespaceAll': 'সমস্ত নেমস্পেস', + 'knowledgeGaps.namespaceLabel': 'নেমস্পেস', + 'knowledgeGaps.objectOnly': 'উল্লিখিত, কখনো বর্ণিত নয়', + 'knowledgeGaps.ratioCaption': '{ratio}% সত্তা বিরলভাবে সংযুক্ত', + 'knowledgeGaps.retry': 'পুনরায় চেষ্টা', + 'knowledgeGaps.title': 'জ্ঞান ফাঁক', + 'knowledgeGaps.truncated': '{total} বিরল সত্তার মধ্যে {shown} দেখাচ্ছি।', + 'memory.tab.gaps': 'ফাঁক', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index d3400e8fa03..8b7a6eb2bdd 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4565,6 +4565,28 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Lokalen Speicher ablehnen', 'pages.settings.account.security': 'Sicherheit', 'pages.settings.account.securityDesc': 'Geheimnisspeicher-Modus und Schlüsselbund-Status', + 'knowledgeGaps.allConnected': 'Jede Entität ist gut verbunden — keine Lücken gefunden.', + 'knowledgeGaps.empty': 'Noch kein Wissensgraph.', + 'knowledgeGaps.emptyHint': + 'Während der Assistent Fakten erfasst, erscheinen hier dünn verbundene Entitäten.', + 'knowledgeGaps.errorPrefix': 'Graph konnte nicht geladen werden:', + 'knowledgeGaps.heading': 'Dünne Entitäten', + 'knowledgeGaps.intro': + 'Das Gegenteil von Zentralität: Entitäten, über die der Assistent kaum etwas erfasst hat — Waisen (keine Verbindungen) und Blätter (eine einzige Verbindung) — die besten Kandidaten, mehr über sie zu lernen.', + 'knowledgeGaps.kindLeaf': 'Blatt', + 'knowledgeGaps.kindOrphan': 'Waise', + 'knowledgeGaps.loading': 'Suche Lücken…', + 'knowledgeGaps.metricEntities': 'Entitäten', + 'knowledgeGaps.metricLeaves': 'Blätter', + 'knowledgeGaps.metricOrphans': 'Waisen', + 'knowledgeGaps.namespaceAll': 'Alle Namensräume', + 'knowledgeGaps.namespaceLabel': 'Namensraum', + 'knowledgeGaps.objectOnly': 'erwähnt, nie beschrieben', + 'knowledgeGaps.ratioCaption': '{ratio}% der Entitäten sind dünn verbunden', + 'knowledgeGaps.retry': 'Wiederholen', + 'knowledgeGaps.title': 'Wissenslücken', + 'knowledgeGaps.truncated': 'Zeige {shown} von {total} dünnen Entitäten.', + 'memory.tab.gaps': 'Lücken', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 8f06dc1abcd..0e97c77d6f9 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -307,6 +307,7 @@ const en: TranslationMap = { 'memory.tab.namespaces': 'Namespaces', 'memory.tab.timeline': 'Timeline', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.gaps': 'Gaps', 'memory.tab.settings': 'Settings', 'memory.tab.council': 'Council', 'modelCouncil.title': 'Model Council', @@ -369,6 +370,27 @@ const en: 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.', + 'knowledgeGaps.title': 'Knowledge Gaps', + 'knowledgeGaps.intro': + 'The inverse of centrality: entities the assistant has barely recorded anything about — orphans (no connections) and leaves (a single connection) — the best candidates to learn more about.', + 'knowledgeGaps.loading': 'Finding gaps…', + 'knowledgeGaps.errorPrefix': 'Could not load the graph:', + 'knowledgeGaps.retry': 'Retry', + 'knowledgeGaps.namespaceLabel': 'Namespace', + 'knowledgeGaps.namespaceAll': 'All namespaces', + 'knowledgeGaps.empty': 'No knowledge graph yet.', + 'knowledgeGaps.emptyHint': + 'As the assistant records facts, sparsely-connected entities will surface here.', + 'knowledgeGaps.metricEntities': 'Entities', + 'knowledgeGaps.metricOrphans': 'Orphans', + 'knowledgeGaps.metricLeaves': 'Leaves', + 'knowledgeGaps.ratioCaption': '{ratio}% of entities are sparsely connected', + 'knowledgeGaps.allConnected': 'Every entity is well-connected — no gaps found.', + 'knowledgeGaps.heading': 'Sparse entities', + 'knowledgeGaps.kindOrphan': 'orphan', + 'knowledgeGaps.kindLeaf': 'leaf', + 'knowledgeGaps.objectOnly': 'mentioned, never described', + 'knowledgeGaps.truncated': 'Showing {shown} of {total} sparse entities.', 'graphCentrality.title': 'Knowledge Graph Centrality', 'graphCentrality.intro': 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', @@ -2505,7 +2527,7 @@ const en: TranslationMap = { 'app.openhumanLink.notifications.send': 'Send test notification', 'app.openhumanLink.notifications.sendFailed': "Couldn't send: {error}", 'app.openhumanLink.notifications.sent': - "Test notification sent. If you didn't receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.", + 'Test notification sent. If you didn’t receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.', 'app.openhumanLink.skipForNow': 'Skip for now', 'app.openhumanLink.telegramUnavailable': 'Telegram unavailable', 'app.openhumanLink.title.accounts': 'Connect your apps', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 23246f418bc..1c90787c085 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4531,6 +4531,28 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Rechazar almacenamiento local', 'pages.settings.account.security': 'Seguridad', 'pages.settings.account.securityDesc': 'Modo de almacenamiento de secretos y estado del llavero', + 'knowledgeGaps.allConnected': 'Cada entidad está bien conectada — no se encontraron brechas.', + 'knowledgeGaps.empty': 'Aún no hay grafo de conocimiento.', + 'knowledgeGaps.emptyHint': + 'A medida que el asistente registra hechos, las entidades poco conectadas aparecerán aquí.', + 'knowledgeGaps.errorPrefix': 'No se pudo cargar el grafo:', + 'knowledgeGaps.heading': 'Entidades escasas', + 'knowledgeGaps.intro': + 'Lo inverso de la centralidad: entidades sobre las que el asistente apenas ha registrado nada — huérfanos (sin conexiones) y hojas (una sola conexión) — los mejores candidatos para aprender más.', + 'knowledgeGaps.kindLeaf': 'hoja', + 'knowledgeGaps.kindOrphan': 'huérfano', + 'knowledgeGaps.loading': 'Buscando brechas…', + 'knowledgeGaps.metricEntities': 'Entidades', + 'knowledgeGaps.metricLeaves': 'Hojas', + 'knowledgeGaps.metricOrphans': 'Huérfanos', + 'knowledgeGaps.namespaceAll': 'Todos los espacios de nombres', + 'knowledgeGaps.namespaceLabel': 'Espacio de nombres', + 'knowledgeGaps.objectOnly': 'mencionado, nunca descrito', + 'knowledgeGaps.ratioCaption': '{ratio}% de las entidades están poco conectadas', + 'knowledgeGaps.retry': 'Reintentar', + 'knowledgeGaps.title': 'Brechas de conocimiento', + 'knowledgeGaps.truncated': 'Mostrando {shown} de {total} entidades escasas.', + 'memory.tab.gaps': 'Lagunas', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index a0e97523d7c..77269eea4b0 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4546,6 +4546,28 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Refuser le stockage local', 'pages.settings.account.security': 'Sécurité', 'pages.settings.account.securityDesc': 'Mode de stockage des secrets et état du trousseau', + 'knowledgeGaps.allConnected': 'Chaque entité est bien connectée — aucune lacune trouvée.', + 'knowledgeGaps.empty': 'Pas encore de graphe de connaissances.', + 'knowledgeGaps.emptyHint': + "À mesure que l'assistant enregistre des faits, les entités peu connectées apparaîtront ici.", + 'knowledgeGaps.errorPrefix': 'Impossible de charger le graphe :', + 'knowledgeGaps.heading': 'Entités clairsemées', + 'knowledgeGaps.intro': + "L'inverse de la centralité : les entités sur lesquelles l'assistant n'a presque rien enregistré — orphelins (aucune connexion) et feuilles (une seule connexion) — les meilleurs candidats pour en apprendre davantage.", + 'knowledgeGaps.kindLeaf': 'feuille', + 'knowledgeGaps.kindOrphan': 'orphelin', + 'knowledgeGaps.loading': 'Recherche de lacunes…', + 'knowledgeGaps.metricEntities': 'Entités', + 'knowledgeGaps.metricLeaves': 'Feuilles', + 'knowledgeGaps.metricOrphans': 'Orphelins', + 'knowledgeGaps.namespaceAll': 'Tous les espaces de noms', + 'knowledgeGaps.namespaceLabel': 'Espace de noms', + 'knowledgeGaps.objectOnly': 'mentionné, jamais décrit', + 'knowledgeGaps.ratioCaption': '{ratio}% des entités sont peu connectées', + 'knowledgeGaps.retry': 'Réessayer', + 'knowledgeGaps.title': 'Lacunes de connaissance', + 'knowledgeGaps.truncated': 'Affichage de {shown} sur {total} entités clairsemées.', + 'memory.tab.gaps': 'Lacunes', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index a4fc134bf51..f36c0209af0 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4455,6 +4455,27 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'स्थानीय भंडारण अस्वीकार करें', 'pages.settings.account.security': 'सुरक्षा', 'pages.settings.account.securityDesc': 'रहस्य भंडारण मोड और कीचेन स्थिति', + 'knowledgeGaps.allConnected': 'हर इकाई अच्छी तरह जुड़ी है — कोई कमी नहीं मिली।', + 'knowledgeGaps.empty': 'अभी कोई नॉलेज ग्राफ नहीं।', + 'knowledgeGaps.emptyHint': 'जैसे-जैसे सहायक तथ्य दर्ज करता है, कम-जुड़ी इकाइयाँ यहाँ उभरेंगी।', + 'knowledgeGaps.errorPrefix': 'ग्राफ लोड नहीं हो सका:', + 'knowledgeGaps.heading': 'विरल इकाइयाँ', + 'knowledgeGaps.intro': + 'केंद्रीयता का व्युत्क्रम: वे इकाइयाँ जिनके बारे में सहायक ने मुश्किल से कुछ दर्ज किया है — अनाथ (कोई कनेक्शन नहीं) और पत्तियाँ (एक कनेक्शन) — अधिक जानने के लिए सर्वोत्तम उम्मीदवार।', + 'knowledgeGaps.kindLeaf': 'पत्ती', + 'knowledgeGaps.kindOrphan': 'अनाथ', + 'knowledgeGaps.loading': 'कमियाँ ढूंढी जा रही हैं…', + 'knowledgeGaps.metricEntities': 'इकाइयाँ', + 'knowledgeGaps.metricLeaves': 'पत्तियाँ', + 'knowledgeGaps.metricOrphans': 'अनाथ', + 'knowledgeGaps.namespaceAll': 'सभी नेमस्पेस', + 'knowledgeGaps.namespaceLabel': 'नेमस्पेस', + 'knowledgeGaps.objectOnly': 'उल्लेखित, कभी वर्णित नहीं', + 'knowledgeGaps.ratioCaption': '{ratio}% इकाइयाँ विरल रूप से जुड़ी हैं', + 'knowledgeGaps.retry': 'पुनः प्रयास', + 'knowledgeGaps.title': 'ज्ञान कमियाँ', + 'knowledgeGaps.truncated': '{total} विरल इकाइयों में से {shown} दिखा रहे हैं।', + 'memory.tab.gaps': 'अंतराल', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 37c2055a76b..0658db07069 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4465,6 +4465,29 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Tolak penyimpanan lokal', 'pages.settings.account.security': 'Keamanan', 'pages.settings.account.securityDesc': 'Mode penyimpanan rahasia dan status keychain', + 'knowledgeGaps.allConnected': + 'Setiap entitas terhubung dengan baik — tidak ada celah yang ditemukan.', + 'knowledgeGaps.empty': 'Belum ada graf pengetahuan.', + 'knowledgeGaps.emptyHint': + 'Saat asisten mencatat fakta, entitas yang jarang terhubung akan muncul di sini.', + 'knowledgeGaps.errorPrefix': 'Tidak dapat memuat graf:', + 'knowledgeGaps.heading': 'Entitas jarang', + 'knowledgeGaps.intro': + 'Kebalikan dari sentralitas: entitas yang hampir tidak dicatat apa-apa oleh asisten — yatim (tanpa koneksi) dan daun (satu koneksi) — kandidat terbaik untuk dipelajari lebih lanjut.', + 'knowledgeGaps.kindLeaf': 'daun', + 'knowledgeGaps.kindOrphan': 'yatim', + 'knowledgeGaps.loading': 'Mencari celah…', + 'knowledgeGaps.metricEntities': 'Entitas', + 'knowledgeGaps.metricLeaves': 'Daun', + 'knowledgeGaps.metricOrphans': 'Yatim', + 'knowledgeGaps.namespaceAll': 'Semua ruang nama', + 'knowledgeGaps.namespaceLabel': 'Ruang nama', + 'knowledgeGaps.objectOnly': 'disebutkan, tidak pernah dijelaskan', + 'knowledgeGaps.ratioCaption': '{ratio}% entitas terhubung jarang', + 'knowledgeGaps.retry': 'Coba lagi', + 'knowledgeGaps.title': 'Celah Pengetahuan', + 'knowledgeGaps.truncated': 'Menampilkan {shown} dari {total} entitas jarang.', + 'memory.tab.gaps': 'Kesenjangan', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 8b33e815b98..06130a74529 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4523,6 +4523,28 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Rifiuta archiviazione locale', 'pages.settings.account.security': 'Sicurezza', 'pages.settings.account.securityDesc': 'Modalità archiviazione segreti e stato del portachiavi', + 'knowledgeGaps.allConnected': 'Ogni entità è ben collegata — nessuna lacuna trovata.', + 'knowledgeGaps.empty': 'Ancora nessun grafo della conoscenza.', + 'knowledgeGaps.emptyHint': + "Man mano che l'assistente registra fatti, qui appariranno le entità scarsamente collegate.", + 'knowledgeGaps.errorPrefix': 'Impossibile caricare il grafo:', + 'knowledgeGaps.heading': 'Entità sparse', + 'knowledgeGaps.intro': + "L'inverso della centralità: entità su cui l'assistente ha registrato a malapena qualcosa — orfani (nessuna connessione) e foglie (una sola connessione) — i migliori candidati su cui imparare di più.", + 'knowledgeGaps.kindLeaf': 'foglia', + 'knowledgeGaps.kindOrphan': 'orfano', + 'knowledgeGaps.loading': 'Ricerca lacune…', + 'knowledgeGaps.metricEntities': 'Entità', + 'knowledgeGaps.metricLeaves': 'Foglie', + 'knowledgeGaps.metricOrphans': 'Orfani', + 'knowledgeGaps.namespaceAll': 'Tutti gli spazi dei nomi', + 'knowledgeGaps.namespaceLabel': 'Spazio dei nomi', + 'knowledgeGaps.objectOnly': 'menzionato, mai descritto', + 'knowledgeGaps.ratioCaption': '{ratio}% delle entità sono scarsamente collegate', + 'knowledgeGaps.retry': 'Riprova', + 'knowledgeGaps.title': 'Lacune di conoscenza', + 'knowledgeGaps.truncated': 'Mostrando {shown} di {total} entità sparse.', + 'memory.tab.gaps': 'Lacune', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 5d87d2f9b4e..33cc01d52a5 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4413,6 +4413,28 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '로컬 저장소 거부', 'pages.settings.account.security': '보안', 'pages.settings.account.securityDesc': '비밀 저장 모드 및 키체인 상태', + 'knowledgeGaps.allConnected': '모든 엔티티가 잘 연결되어 있습니다 — 간격이 발견되지 않았습니다.', + 'knowledgeGaps.empty': '아직 지식 그래프가 없습니다.', + 'knowledgeGaps.emptyHint': + '어시스턴트가 사실을 기록함에 따라, 드물게 연결된 엔티티가 여기에 나타납니다.', + 'knowledgeGaps.errorPrefix': '그래프를 불러올 수 없습니다:', + 'knowledgeGaps.heading': '희소 엔티티', + 'knowledgeGaps.intro': + '중심성의 역: 어시스턴트가 거의 아무것도 기록하지 않은 엔티티 — 고아(연결 없음)와 잎(단일 연결) — 더 많이 학습할 최고의 후보입니다.', + 'knowledgeGaps.kindLeaf': '잎', + 'knowledgeGaps.kindOrphan': '고아', + 'knowledgeGaps.loading': '간격 찾는 중…', + 'knowledgeGaps.metricEntities': '엔티티', + 'knowledgeGaps.metricLeaves': '잎', + 'knowledgeGaps.metricOrphans': '고아', + 'knowledgeGaps.namespaceAll': '모든 네임스페이스', + 'knowledgeGaps.namespaceLabel': '네임스페이스', + 'knowledgeGaps.objectOnly': '언급됨, 설명 없음', + 'knowledgeGaps.ratioCaption': '엔티티의 {ratio}%가 희소하게 연결됨', + 'knowledgeGaps.retry': '다시 시도', + 'knowledgeGaps.title': '지식 간격', + 'knowledgeGaps.truncated': '{total}개 희소 엔티티 중 {shown}개 표시 중.', + 'memory.tab.gaps': '지식 공백', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 1b9c784d2ee..7be5f667f1e 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4522,6 +4522,28 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Odmów lokalnego przechowywania', 'pages.settings.account.security': 'Bezpieczeństwo', 'pages.settings.account.securityDesc': 'Tryb przechowywania sekretów i stan pęku kluczy', + 'knowledgeGaps.allConnected': 'Każda encja jest dobrze połączona — nie znaleziono luk.', + 'knowledgeGaps.empty': 'Jeszcze brak grafu wiedzy.', + 'knowledgeGaps.emptyHint': + 'Gdy asystent zapisuje fakty, słabo połączone encje pojawią się tutaj.', + 'knowledgeGaps.errorPrefix': 'Nie udało się załadować grafu:', + 'knowledgeGaps.heading': 'Rzadkie encje', + 'knowledgeGaps.intro': + 'Odwrotność centralności: encje, o których asystent ledwo cokolwiek zarejestrował — sieroty (brak połączeń) i liście (jedno połączenie) — najlepsi kandydaci, aby się o nich więcej dowiedzieć.', + 'knowledgeGaps.kindLeaf': 'liść', + 'knowledgeGaps.kindOrphan': 'sierota', + 'knowledgeGaps.loading': 'Szukanie luk…', + 'knowledgeGaps.metricEntities': 'Encje', + 'knowledgeGaps.metricLeaves': 'Liście', + 'knowledgeGaps.metricOrphans': 'Sieroty', + 'knowledgeGaps.namespaceAll': 'Wszystkie przestrzenie nazw', + 'knowledgeGaps.namespaceLabel': 'Przestrzeń nazw', + 'knowledgeGaps.objectOnly': 'wspomniane, nigdy opisane', + 'knowledgeGaps.ratioCaption': '{ratio}% encji jest słabo połączonych', + 'knowledgeGaps.retry': 'Spróbuj ponownie', + 'knowledgeGaps.title': 'Luki w wiedzy', + 'knowledgeGaps.truncated': 'Pokazuję {shown} z {total} rzadkich encji.', + 'memory.tab.gaps': 'Luki', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 3a8a6f59b40..452171bc726 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4520,6 +4520,28 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Recusar armazenamento local', 'pages.settings.account.security': 'Segurança', 'pages.settings.account.securityDesc': 'Modo de armazenamento de segredos e status do chaveiro', + 'knowledgeGaps.allConnected': 'Cada entidade está bem conectada — nenhuma lacuna encontrada.', + 'knowledgeGaps.empty': 'Ainda sem grafo de conhecimento.', + 'knowledgeGaps.emptyHint': + 'À medida que o assistente registra fatos, entidades pouco conectadas aparecerão aqui.', + 'knowledgeGaps.errorPrefix': 'Não foi possível carregar o grafo:', + 'knowledgeGaps.heading': 'Entidades esparsas', + 'knowledgeGaps.intro': + 'O inverso da centralidade: entidades sobre as quais o assistente mal registrou algo — órfãos (sem conexões) e folhas (uma única conexão) — os melhores candidatos para aprender mais.', + 'knowledgeGaps.kindLeaf': 'folha', + 'knowledgeGaps.kindOrphan': 'órfão', + 'knowledgeGaps.loading': 'Buscando lacunas…', + 'knowledgeGaps.metricEntities': 'Entidades', + 'knowledgeGaps.metricLeaves': 'Folhas', + 'knowledgeGaps.metricOrphans': 'Órfãos', + 'knowledgeGaps.namespaceAll': 'Todos os espaços de nomes', + 'knowledgeGaps.namespaceLabel': 'Espaço de nomes', + 'knowledgeGaps.objectOnly': 'mencionado, nunca descrito', + 'knowledgeGaps.ratioCaption': '{ratio}% das entidades estão pouco conectadas', + 'knowledgeGaps.retry': 'Tentar novamente', + 'knowledgeGaps.title': 'Lacunas de conhecimento', + 'knowledgeGaps.truncated': 'Mostrando {shown} de {total} entidades esparsas.', + 'memory.tab.gaps': 'Lacunas', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index fae5422b439..515989b7b44 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4491,6 +4491,28 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Отклонить локальное хранилище', 'pages.settings.account.security': 'Безопасность', 'pages.settings.account.securityDesc': 'Режим хранения секретов и статус связки ключей', + 'knowledgeGaps.allConnected': 'Каждая сущность хорошо связана — пробелов не найдено.', + 'knowledgeGaps.empty': 'Пока нет графа знаний.', + 'knowledgeGaps.emptyHint': + 'По мере того как ассистент фиксирует факты, здесь появятся слабо связанные сущности.', + 'knowledgeGaps.errorPrefix': 'Не удалось загрузить граф:', + 'knowledgeGaps.heading': 'Разрежённые сущности', + 'knowledgeGaps.intro': + 'Противоположность центральности: сущности, о которых ассистент почти ничего не зафиксировал — сироты (без связей) и листья (одна связь) — лучшие кандидаты, чтобы узнать о них больше.', + 'knowledgeGaps.kindLeaf': 'лист', + 'knowledgeGaps.kindOrphan': 'сирота', + 'knowledgeGaps.loading': 'Поиск пробелов…', + 'knowledgeGaps.metricEntities': 'Сущности', + 'knowledgeGaps.metricLeaves': 'Листья', + 'knowledgeGaps.metricOrphans': 'Сироты', + 'knowledgeGaps.namespaceAll': 'Все пространства имён', + 'knowledgeGaps.namespaceLabel': 'Пространство имён', + 'knowledgeGaps.objectOnly': 'упомянуто, но не описано', + 'knowledgeGaps.ratioCaption': '{ratio}% сущностей слабо связаны', + 'knowledgeGaps.retry': 'Повторить', + 'knowledgeGaps.title': 'Пробелы в знаниях', + 'knowledgeGaps.truncated': 'Показано {shown} из {total} разрежённых сущностей.', + 'memory.tab.gaps': 'Пробелы', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d88e26c9a41..0ae633054bb 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4234,6 +4234,27 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '拒绝本地存储', 'pages.settings.account.security': '安全', 'pages.settings.account.securityDesc': '密钥存储模式和密钥链状态', + 'knowledgeGaps.allConnected': '每个实体连接良好——未发现缺口。', + 'knowledgeGaps.empty': '暂无知识图。', + 'knowledgeGaps.emptyHint': '随着助手记录事实,稀疏连接的实体将在此显现。', + 'knowledgeGaps.errorPrefix': '无法加载图:', + 'knowledgeGaps.heading': '稀疏实体', + 'knowledgeGaps.intro': + '中心性的反面:助手几乎没有记录任何信息的实体——孤立(无连接)和叶子(单一连接)——是最值得进一步了解的候选。', + 'knowledgeGaps.kindLeaf': '叶子', + 'knowledgeGaps.kindOrphan': '孤立', + 'knowledgeGaps.loading': '正在查找缺口…', + 'knowledgeGaps.metricEntities': '实体', + 'knowledgeGaps.metricLeaves': '叶子', + 'knowledgeGaps.metricOrphans': '孤立', + 'knowledgeGaps.namespaceAll': '所有命名空间', + 'knowledgeGaps.namespaceLabel': '命名空间', + 'knowledgeGaps.objectOnly': '已提及,从未描述', + 'knowledgeGaps.ratioCaption': '{ratio}% 的实体连接稀疏', + 'knowledgeGaps.retry': '重试', + 'knowledgeGaps.title': '知识缺口', + 'knowledgeGaps.truncated': '显示 {total} 个稀疏实体中的 {shown} 个。', + 'memory.tab.gaps': '知识缺口', }; export default messages; diff --git a/app/src/lib/memory/knowledgeGaps.test.ts b/app/src/lib/memory/knowledgeGaps.test.ts new file mode 100644 index 00000000000..c88d245df35 --- /dev/null +++ b/app/src/lib/memory/knowledgeGaps.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeKnowledgeGaps } from './knowledgeGaps'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('computeKnowledgeGaps', () => { + it('returns an empty report for no relations', () => { + expect(computeKnowledgeGaps([])).toEqual({ + gaps: [], + orphanCount: 0, + leafCount: 0, + connectedCount: 0, + entityCount: 0, + gapRatio: 0, + }); + }); + + it('classifies orphans, leaves, and connected entities', () => { + // A->B->C chain + a self-loop orphan D. + const r = computeKnowledgeGaps([rel('A', 'B'), rel('B', 'C'), rel('D', 'D')]); + expect(r.entityCount).toBe(4); + expect(r.orphanCount).toBe(1); // D (self-loop only) + expect(r.leafCount).toBe(2); // A and C + expect(r.connectedCount).toBe(1); // B (degree 2) + expect(r.gapRatio).toBeCloseTo(0.75, 12); + // Orphan first, then leaves by id asc. + expect(r.gaps.map(g => g.id)).toEqual(['D', 'A', 'C']); + expect(r.gaps[0]).toEqual({ id: 'D', degree: 0, kind: 'orphan', objectOnly: false }); + }); + + it('flags entities that appear only as an object (mentioned but never described)', () => { + const r = computeKnowledgeGaps([rel('X', 'Y')]); + const byId = Object.fromEntries(r.gaps.map(g => [g.id, g])); + expect(byId.X.objectOnly).toBe(false); // X is a subject + expect(byId.Y.objectOnly).toBe(true); // Y only ever an object + expect(byId.X.kind).toBe('leaf'); + expect(byId.Y.kind).toBe('leaf'); + }); + + it('treats a self-loop-only entity as an orphan (degree 0)', () => { + const r = computeKnowledgeGaps([rel('A', 'A')]); + expect(r.entityCount).toBe(1); + expect(r.gaps).toEqual([{ id: 'A', degree: 0, kind: 'orphan', objectOnly: false }]); + }); + + it('excludes well-connected entities from the gap list', () => { + const r = computeKnowledgeGaps([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + expect(r.gaps).toEqual([]); + expect(r.connectedCount).toBe(3); + expect(r.gapRatio).toBe(0); + }); + + it('is invariant to relation order', () => { + const triples = [rel('A', 'B'), rel('B', 'C'), rel('D', 'D')]; + const forward = computeKnowledgeGaps(triples); + const reversed = computeKnowledgeGaps([...triples].reverse()); + expect(reversed).toEqual(forward); + }); + + it('drops malformed relations with a non-string endpoint', () => { + const malformed = { ...rel('A', 'B'), object: null as unknown as string }; + const r = computeKnowledgeGaps([rel('A', 'B'), malformed, rel('C', 'D')]); + // A,B,C,D only; the null-object row is ignored. + expect(r.entityCount).toBe(4); + }); +}); diff --git a/app/src/lib/memory/knowledgeGaps.ts b/app/src/lib/memory/knowledgeGaps.ts new file mode 100644 index 00000000000..c992f1454f5 --- /dev/null +++ b/app/src/lib/memory/knowledgeGaps.ts @@ -0,0 +1,110 @@ +/** + * Knowledge Gaps — pure sparsity-analysis engine. + * + * The inverse of the centrality lens: instead of the hubs the assistant knows + * the most about, this surfaces the THIN spots — entities it has barely + * recorded anything about, which are the best candidates for the assistant to + * enrich. Each entity is classified by its connectivity: + * + * - orphan 0 connections (appears only via a self-loop) + * - leaf exactly 1 connection (a dead-end stub) + * - connected 2+ connections (well-situated; not a gap) + * + * Each gap entity also records whether it appears only as an OBJECT (never a + * subject) — "mentioned but never described" — an even stronger stub signal. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. Output depends only on the (subject, object) structure, never on + * insertion order. Direction is ignored for the connection count (an + * association is symmetric); self-loops do not count as a connection. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export type GapKind = 'orphan' | 'leaf'; + +export interface GapEntity { + id: string; + degree: number; // distinct undirected neighbours (0 or 1 for a gap) + kind: GapKind; + objectOnly: boolean; // appears only as an object, never as a subject +} + +export interface KnowledgeGapsReport { + gaps: GapEntity[]; // orphans + leaves, sorted: orphan before leaf, then id asc + orphanCount: number; + leafCount: number; + connectedCount: number; // entities with 2+ connections (not gaps) + entityCount: number; + gapRatio: number; // (orphan + leaf) / entityCount, 0 when no entities +} + +const EMPTY_REPORT: KnowledgeGapsReport = { + gaps: [], + orphanCount: 0, + leafCount: 0, + connectedCount: 0, + entityCount: 0, + gapRatio: 0, +}; + +function compareIds(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * Detect knowledge-gap (orphan / leaf) entities. Pure function of `relations`. + */ +export function computeKnowledgeGaps(relations: GraphRelation[]): KnowledgeGapsReport { + const neighbours = new Map>(); + const subjects = new Set(); // entities that ever appear as a subject + const ensure = (id: string): Set => { + let set = neighbours.get(id); + if (!set) { + set = new Set(); + neighbours.set(id, set); + } + return set; + }; + for (const relation of relations) { + const { subject, object } = relation; + if (typeof subject !== 'string' || typeof object !== 'string') continue; + ensure(subject); + ensure(object); + subjects.add(subject); + if (subject === object) continue; // self-loop is not a connection + ensure(subject).add(object); + ensure(object).add(subject); + } + + const entityCount = neighbours.size; + if (entityCount === 0) return EMPTY_REPORT; + + const gaps: GapEntity[] = []; + let orphanCount = 0; + let leafCount = 0; + let connectedCount = 0; + for (const [id, set] of neighbours) { + const degree = set.size; + if (degree >= 2) { + connectedCount += 1; + continue; + } + const kind: GapKind = degree === 0 ? 'orphan' : 'leaf'; + if (kind === 'orphan') orphanCount += 1; + else leafCount += 1; + gaps.push({ id, degree, kind, objectOnly: !subjects.has(id) }); + } + + // Sort: orphans before leaves (rank 0 < 1), then id asc (deterministic). + const rank = (k: GapKind): number => (k === 'orphan' ? 0 : 1); + gaps.sort((a, b) => rank(a.kind) - rank(b.kind) || compareIds(a.id, b.id)); + + return { + gaps, + orphanCount, + leafCount, + connectedCount, + entityCount, + gapRatio: (orphanCount + leafCount) / entityCount, + }; +} diff --git a/app/src/services/api/knowledgeGapsApi.test.ts b/app/src/services/api/knowledgeGapsApi.test.ts new file mode 100644 index 00000000000..33f9dc5f4d0 --- /dev/null +++ b/app/src/services/api/knowledgeGapsApi.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeKnowledgeGaps } from '../../lib/memory/knowledgeGaps'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { knowledgeGapsApi, loadKnowledgeGaps, loadNamespaces } from './knowledgeGapsApi'; + +const mockGraphQuery = vi.fn(); +const mockListNamespaces = vi.fn(); + +vi.mock('../../utils/tauriCommands/memory', () => ({ + memoryGraphQuery: (...args: unknown[]) => mockGraphQuery(...args), + memoryListNamespaces: (...args: unknown[]) => mockListNamespaces(...args), +})); + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'work', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('knowledgeGapsApi.loadKnowledgeGaps', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('passes the namespace through and returns the engine report', async () => { + const triples = [rel('A', 'B'), rel('B', 'C')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadKnowledgeGaps('work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeKnowledgeGaps(triples)); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadKnowledgeGaps(); + expect(mockGraphQuery).toHaveBeenCalledWith(undefined); + expect(out.entityCount).toBe(0); + }); + + it('propagates query errors', async () => { + mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable')); + await expect(loadKnowledgeGaps()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('knowledgeGapsApi.loadNamespaces', () => { + beforeEach(() => { + mockListNamespaces.mockReset(); + }); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('knowledgeGapsApi object', () => { + it('exposes the public surface', () => { + expect(typeof knowledgeGapsApi.loadKnowledgeGaps).toBe('function'); + expect(typeof knowledgeGapsApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/knowledgeGapsApi.ts b/app/src/services/api/knowledgeGapsApi.ts new file mode 100644 index 00000000000..9c0aa0eda15 --- /dev/null +++ b/app/src/services/api/knowledgeGapsApi.ts @@ -0,0 +1,28 @@ +/** + * RPC facade for Knowledge Gaps. + * + * Adds ZERO new core surface. Composes two already-shipped JSON-RPC wrappers: + * - memoryGraphQuery (openhuman.memory_graph_query) — the triples + * - memoryListNamespaces (openhuman.memory_list_namespaces) — the selector + * and delegates analysis to the pure engine. Read-only — nothing is persisted. + */ +import debug from 'debug'; + +import { computeKnowledgeGaps, type KnowledgeGapsReport } from '../../lib/memory/knowledgeGaps'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('knowledge-gaps:api'); + +/** Fetch the triples for a namespace (or all) and detect sparse/stub entities. */ +export async function loadKnowledgeGaps(namespace?: string): Promise { + const relations = await memoryGraphQuery(namespace); + log('loadKnowledgeGaps namespace=%s relations=%d', namespace ?? '(all)', relations.length); + return computeKnowledgeGaps(relations); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const knowledgeGapsApi = { loadKnowledgeGaps, loadNamespaces };