From 62cbd99ccbaaa87bd7109ff2d654344b9075796f Mon Sep 17 00:00:00 2001 From: Aashir Athar Date: Fri, 29 May 2026 21:02:54 +0500 Subject: [PATCH 1/3] feat(intelligence): add Duplicate Entity Detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new read-only "Duplicates" tab that surfaces entities recorded under different spellings (case / whitespace / punctuation) as likely duplicates — the natural companion to the centrality lens, which deliberately keeps variants distinct. Helps the user spot fragmentation in their knowledge graph. - Pure deterministic engine (lib/memory/entityDuplicates.ts): groups entities by a normalized key (trim, collapse whitespace, lowercase) and keeps clusters with >= 2 distinct raw spellings; each variant carries its distinct-neighbour degree so the most-connected (likely-canonical) spelling is visible. No clock, no RNG; deterministic variant + cluster ordering. Never mutates stored names. - Zero new core surface: reuses memoryGraphQuery + memoryListNamespaces. Read-only, recomputed from the live graph. - Container guards the load with a request token; namespace selector; summary tiles + cluster list of variant chips (with degree), a distinct "all clean" state when no duplicates, and a truncation note. i18n across all 13 locales. Co-Authored-By: Claude Opus 4.7 --- .../EntityDuplicatesPanel.test.tsx | 66 +++++++ .../intelligence/EntityDuplicatesPanel.tsx | 172 ++++++++++++++++++ .../intelligence/EntityDuplicatesTab.test.tsx | 61 +++++++ .../intelligence/EntityDuplicatesTab.tsx | 79 ++++++++ app/src/lib/i18n/en.ts | 18 ++ app/src/lib/memory/entityDuplicates.test.ts | 126 +++++++++++++ app/src/lib/memory/entityDuplicates.ts | 114 ++++++++++++ .../services/api/entityDuplicatesApi.test.ts | 72 ++++++++ app/src/services/api/entityDuplicatesApi.ts | 29 +++ 9 files changed, 737 insertions(+) create mode 100644 app/src/components/intelligence/EntityDuplicatesPanel.test.tsx create mode 100644 app/src/components/intelligence/EntityDuplicatesPanel.tsx create mode 100644 app/src/components/intelligence/EntityDuplicatesTab.test.tsx create mode 100644 app/src/components/intelligence/EntityDuplicatesTab.tsx create mode 100644 app/src/lib/memory/entityDuplicates.test.ts create mode 100644 app/src/lib/memory/entityDuplicates.ts create mode 100644 app/src/services/api/entityDuplicatesApi.test.ts create mode 100644 app/src/services/api/entityDuplicatesApi.ts diff --git a/app/src/components/intelligence/EntityDuplicatesPanel.test.tsx b/app/src/components/intelligence/EntityDuplicatesPanel.test.tsx new file mode 100644 index 0000000000..3bf18c23c5 --- /dev/null +++ b/app/src/components/intelligence/EntityDuplicatesPanel.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeEntityDuplicates } from '../../lib/memory/entityDuplicates'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import EntityDuplicatesPanel from './EntityDuplicatesPanel'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const dupReport = computeEntityDuplicates([ + rel('Alice', 'Bob'), + rel('alice', 'Carol'), + rel(' Alice ', 'Dave'), +]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('entity-duplicates-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 the all-clean message when entities exist but no duplicates', () => { + const clean = computeEntityDuplicates([rel('Alice', 'Bob')]); + render(); + expect( + screen.getByText('No duplicate spellings detected — your entities look clean.') + ).toBeInTheDocument(); + }); + + it('renders duplicate clusters with their variants', () => { + render(); + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByText('Duplicate sets')).toBeInTheDocument(); + expect(screen.getByText('Likely duplicate entities')).toBeInTheDocument(); + // All three spelling variants render. 'Alice' and ' Alice ' both normalize + // to the same visible text under Testing Library, so there are two of them. + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getAllByText('Alice')).toHaveLength(2); + }); +}); diff --git a/app/src/components/intelligence/EntityDuplicatesPanel.tsx b/app/src/components/intelligence/EntityDuplicatesPanel.tsx new file mode 100644 index 0000000000..c60a034c09 --- /dev/null +++ b/app/src/components/intelligence/EntityDuplicatesPanel.tsx @@ -0,0 +1,172 @@ +/** + * Duplicate Entity Detection — presentational view. Pure: renders the duplicate + * clusters (variant chips + degree) and summary tiles. No data fetching, no + * clock, no RNG. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { DuplicateReport } from '../../lib/memory/entityDuplicates'; + +const MAX_CLUSTERS = 50; + +interface EntityDuplicatesPanelProps { + report: DuplicateReport | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const EntityDuplicatesPanel = ({ report, loading, error, onRetry }: EntityDuplicatesPanelProps) => { + const { t } = useT(); + + const intro = ( +
+

{t('entityDuplicates.title')}

+

{t('entityDuplicates.intro')}

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

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

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

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

+

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

+
+
+ ); + } + + const clusters = report.clusters.slice(0, MAX_CLUSTERS); + const truncated = report.clusters.length > MAX_CLUSTERS; + + return ( +
+ {intro} + + {/* Summary tiles */} +
+ {[ + { label: t('entityDuplicates.metricEntities'), value: report.entityCount }, + { label: t('entityDuplicates.metricClusters'), value: report.clusterCount }, + { label: t('entityDuplicates.metricAffected'), value: report.affectedEntities }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+ + {report.clusterCount === 0 ? ( +

+ {t('entityDuplicates.allClean')} +

+ ) : ( +
+

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

+
    + {clusters.map(cluster => ( +
  • +
    + {cluster.variants.map(variant => ( + + + {variant.id || t('entityDuplicates.blankEntity')} + + + {variant.degree} + + + ))} +
    +
  • + ))} +
+ {truncated && ( +

+ {t('entityDuplicates.truncated') + .replace('{shown}', String(clusters.length)) + .replace('{total}', String(report.clusterCount))} +

+ )} +
+ )} +
+ ); +}; + +export default EntityDuplicatesPanel; diff --git a/app/src/components/intelligence/EntityDuplicatesTab.test.tsx b/app/src/components/intelligence/EntityDuplicatesTab.test.tsx new file mode 100644 index 0000000000..88088a92a2 --- /dev/null +++ b/app/src/components/intelligence/EntityDuplicatesTab.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeEntityDuplicates } from '../../lib/memory/entityDuplicates'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import EntityDuplicatesTab from './EntityDuplicatesTab'; + +const mockLoad = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/entityDuplicatesApi', () => ({ + loadEntityDuplicates: (...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 = computeEntityDuplicates([rel('Alice', 'Bob'), rel('alice', 'Carol')]); + +describe('', () => { + beforeEach(() => { + mockLoad.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoad.mockResolvedValue(report); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads on mount and renders the clusters', async () => { + render(); + expect(mockLoad).toHaveBeenCalledWith(undefined); + await waitFor(() => expect(screen.getByText('Likely duplicate entities')).toBeInTheDocument()); + }); + + 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/EntityDuplicatesTab.tsx b/app/src/components/intelligence/EntityDuplicatesTab.tsx new file mode 100644 index 0000000000..f4a6464747 --- /dev/null +++ b/app/src/components/intelligence/EntityDuplicatesTab.tsx @@ -0,0 +1,79 @@ +/** + * Duplicate Entity Detection 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 { DuplicateReport } from '../../lib/memory/entityDuplicates'; +import { loadEntityDuplicates, loadNamespaces } from '../../services/api/entityDuplicatesApi'; +import EntityDuplicatesPanel from './EntityDuplicatesPanel'; + +const EntityDuplicatesTab = () => { + 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 loadEntityDuplicates(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 EntityDuplicatesTab; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ba050c75d5..a0b48d0cff 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.duplicates': 'Duplicates', 'memory.tab.settings': 'Settings', 'memory.tab.council': 'Council', 'modelCouncil.title': 'Model Council', @@ -369,6 +370,23 @@ 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.', + 'entityDuplicates.title': 'Duplicate Entities', + 'entityDuplicates.intro': + 'The graph stores names verbatim, so the same thing can fragment across spellings (case, spacing). These entities normalize to the same name — likely duplicates worth reconciling.', + 'entityDuplicates.loading': 'Scanning for duplicates…', + 'entityDuplicates.errorPrefix': 'Could not load the graph:', + 'entityDuplicates.retry': 'Retry', + 'entityDuplicates.empty': 'No knowledge graph yet.', + 'entityDuplicates.emptyHint': + 'As the assistant records entities, any duplicate spellings will surface here.', + 'entityDuplicates.metricEntities': 'Entities', + 'entityDuplicates.metricClusters': 'Duplicate sets', + 'entityDuplicates.metricAffected': 'Affected', + 'entityDuplicates.allClean': 'No duplicate spellings detected — your entities look clean.', + 'entityDuplicates.heading': 'Likely duplicate entities', + 'entityDuplicates.variantTitle': '{degree} connections', + 'entityDuplicates.blankEntity': '(blank)', + 'entityDuplicates.truncated': 'Showing {shown} of {total} duplicate sets.', '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.', diff --git a/app/src/lib/memory/entityDuplicates.test.ts b/app/src/lib/memory/entityDuplicates.test.ts new file mode 100644 index 0000000000..0bf63ae992 --- /dev/null +++ b/app/src/lib/memory/entityDuplicates.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeEntityDuplicates, normalizeEntity } from './entityDuplicates'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('normalizeEntity', () => { + it('trims, collapses internal whitespace, and lowercases', () => { + expect(normalizeEntity(' Alice ')).toBe('alice'); + expect(normalizeEntity('New York')).toBe('new york'); + expect(normalizeEntity('ALICE')).toBe('alice'); + }); +}); + +describe('computeEntityDuplicates', () => { + it('returns an empty report for no relations', () => { + expect(computeEntityDuplicates([])).toEqual({ + clusters: [], + clusterCount: 0, + affectedEntities: 0, + entityCount: 0, + }); + }); + + it('groups case/whitespace variants into one cluster', () => { + const r = computeEntityDuplicates([ + rel('Alice', 'Bob'), + rel('alice', 'Carol'), + rel(' Alice ', 'Dave'), + ]); + expect(r.entityCount).toBe(6); // 3 Alice-variants + Bob, Carol, Dave + expect(r.clusterCount).toBe(1); + expect(r.clusters[0].normalized).toBe('alice'); + expect(r.clusters[0].variants.map(v => v.id).sort()).toEqual([' Alice ', 'Alice', 'alice']); + expect(r.affectedEntities).toBe(3); + }); + + it('does not flag entities with distinct normalized forms', () => { + const r = computeEntityDuplicates([rel('Alice', 'Bob')]); + expect(r.entityCount).toBe(2); + expect(r.clusters).toEqual([]); + }); + + it('orders variants within a cluster by degree desc', () => { + // "Alice" connects to B and C (degree 2); "alice" connects to D (degree 1). + const r = computeEntityDuplicates([rel('Alice', 'B'), rel('Alice', 'C'), rel('alice', 'D')]); + expect(r.clusters[0].variants[0]).toEqual({ id: 'Alice', degree: 2 }); + expect(r.clusters[0].variants[1]).toEqual({ id: 'alice', degree: 1 }); + expect(r.clusters[0].totalDegree).toBe(3); + }); + + it('ranks clusters by variant count desc', () => { + const r = computeEntityDuplicates([ + // 3-variant cluster on "x" + rel('X', 'a'), + rel('x', 'b'), + rel(' x ', 'c'), + // 2-variant cluster on "y" + rel('Y', 'd'), + rel('y', 'e'), + ]); + expect(r.clusters.map(c => c.variants.length)).toEqual([3, 2]); + expect(r.clusters[0].normalized).toBe('x'); + }); + + it('keeps self-loop-only entities as degree-0 nodes that can still cluster', () => { + const r = computeEntityDuplicates([rel('Alice', 'Alice'), rel('alice', 'alice')]); + expect(r.entityCount).toBe(2); + expect(r.clusterCount).toBe(1); + expect(r.clusters[0].variants).toEqual([ + { id: 'Alice', degree: 0 }, + { id: 'alice', degree: 0 }, + ]); + expect(r.clusters[0].totalDegree).toBe(0); + }); + + it('breaks cluster ties by totalDegree desc, then normalized key asc', () => { + // Two 2-variant clusters; the "b" cluster is more connected (totalDegree 3 vs 2). + const byDegree = computeEntityDuplicates([ + rel('A', 'x'), + rel('a', 'y'), + rel('B', 'p'), + rel('B', 'q'), + rel('b', 'r'), + ]); + expect(byDegree.clusters.map(c => c.normalized)).toEqual(['b', 'a']); + + // Two 2-variant clusters with EQUAL totalDegree -> fall back to key asc. + const byKey = computeEntityDuplicates([ + rel('B', 'p'), + rel('b', 'q'), + rel('A', 'r'), + rel('a', 's'), + ]); + expect(byKey.clusters.map(c => c.normalized)).toEqual(['a', 'b']); + }); + + it('is invariant to relation order', () => { + const triples = [rel('Alice', 'Bob'), rel('alice', 'Carol'), rel(' Alice ', 'Dave')]; + const forward = computeEntityDuplicates(triples); + const reversed = computeEntityDuplicates([...triples].reverse()); + expect(reversed).toEqual(forward); + }); + + it('drops malformed relations with a non-string endpoint', () => { + const malformed = { ...rel('Alice', 'Bob'), object: null as unknown as string }; + const r = computeEntityDuplicates([rel('Alice', 'Bob'), malformed, rel('alice', 'Carol')]); + // Alice/alice still cluster; the null-object row is ignored. + expect(r.clusterCount).toBe(1); + expect(r.clusters[0].variants).toHaveLength(2); + }); +}); diff --git a/app/src/lib/memory/entityDuplicates.ts b/app/src/lib/memory/entityDuplicates.ts new file mode 100644 index 0000000000..6bf429e867 --- /dev/null +++ b/app/src/lib/memory/entityDuplicates.ts @@ -0,0 +1,114 @@ +/** + * Duplicate Entity Detection — pure clustering engine. + * + * The knowledge graph stores entity names verbatim, so the same real-world + * thing can fragment across spellings — "Alice", "alice", " Alice ", + * "alice smith" vs "Alice Smith". This engine groups entities by a normalized + * key and surfaces clusters that hold MORE THAN ONE raw spelling, so the user + * can see (and later reconcile) that fragmentation. It is the natural companion + * to the centrality lens, which deliberately keeps variants distinct. + * + * Normalization (display-only; never mutates the stored names): trim, collapse + * internal whitespace to a single space, and lowercase. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. Output depends only on the entity strings, never on insertion + * order. Each variant carries its degree (distinct incident edges) so the UI + * can show which spelling is the most-connected (likely-canonical) one. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface DuplicateVariant { + id: string; // the raw entity string as stored + degree: number; // distinct neighbours (undirected; repeated edges, both directions of a pair, and self-loops each counted at most once) +} + +export interface DuplicateCluster { + normalized: string; // shared normalized key + variants: DuplicateVariant[]; // >= 2, sorted by degree desc then id asc + totalDegree: number; // sum of variant degrees +} + +export interface DuplicateReport { + clusters: DuplicateCluster[]; // sorted by variant count desc, then totalDegree desc, then key asc + clusterCount: number; + affectedEntities: number; // total raw variants across all clusters + entityCount: number; // total distinct raw entities in the graph +} + +const EMPTY_REPORT: DuplicateReport = { + clusters: [], + clusterCount: 0, + affectedEntities: 0, + entityCount: 0, +}; + +function compareIds(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** Normalized key for grouping: trimmed, single-spaced, lowercased. */ +export function normalizeEntity(id: string): string { + return id.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +/** + * Detect duplicate-entity clusters. Pure function of `relations`. + */ +export function computeEntityDuplicates(relations: GraphRelation[]): DuplicateReport { + // 1. Degree per raw entity (undirected distinct neighbours via a Set so a + // repeated edge or the two directions of one pair don't double-count). + const neighbours = new Map>(); + 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); + if (subject === object) continue; // self-loop adds a node but no neighbour + ensure(subject).add(object); + ensure(object).add(subject); + } + + const entityCount = neighbours.size; + if (entityCount === 0) return EMPTY_REPORT; + + // 2. Group raw entities by normalized key. + const groups = new Map(); + for (const id of neighbours.keys()) { + const key = normalizeEntity(id); + const list = groups.get(key); + if (list) list.push(id); + else groups.set(key, [id]); + } + + // 3. Keep only groups with >= 2 distinct raw spellings. + const clusters: DuplicateCluster[] = []; + let affectedEntities = 0; + for (const [normalized, ids] of groups) { + if (ids.length < 2) continue; + const variants: DuplicateVariant[] = ids + .map(id => ({ id, degree: neighbours.get(id)?.size ?? 0 })) + .sort((a, b) => b.degree - a.degree || compareIds(a.id, b.id)); + const totalDegree = variants.reduce((sum, v) => sum + v.degree, 0); + affectedEntities += variants.length; + clusters.push({ normalized, variants, totalDegree }); + } + + // 4. Rank clusters: most variants first, then most-connected, then key asc. + clusters.sort( + (a, b) => + b.variants.length - a.variants.length || + b.totalDegree - a.totalDegree || + compareIds(a.normalized, b.normalized) + ); + + return { clusters, clusterCount: clusters.length, affectedEntities, entityCount }; +} diff --git a/app/src/services/api/entityDuplicatesApi.test.ts b/app/src/services/api/entityDuplicatesApi.test.ts new file mode 100644 index 0000000000..e71688475b --- /dev/null +++ b/app/src/services/api/entityDuplicatesApi.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeEntityDuplicates } from '../../lib/memory/entityDuplicates'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { entityDuplicatesApi, loadEntityDuplicates, loadNamespaces } from './entityDuplicatesApi'; + +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('entityDuplicatesApi.loadEntityDuplicates', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('passes the namespace through and returns the engine report', async () => { + const triples = [rel('Alice', 'Bob'), rel('alice', 'Carol')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadEntityDuplicates('work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeEntityDuplicates(triples)); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadEntityDuplicates(); + expect(mockGraphQuery).toHaveBeenCalledWith(undefined); + expect(out.clusterCount).toBe(0); + }); + + it('propagates query errors', async () => { + mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable')); + await expect(loadEntityDuplicates()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('entityDuplicatesApi.loadNamespaces', () => { + beforeEach(() => { + mockListNamespaces.mockReset(); + }); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('entityDuplicatesApi object', () => { + it('exposes the public surface', () => { + expect(typeof entityDuplicatesApi.loadEntityDuplicates).toBe('function'); + expect(typeof entityDuplicatesApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/entityDuplicatesApi.ts b/app/src/services/api/entityDuplicatesApi.ts new file mode 100644 index 0000000000..6bd1198f41 --- /dev/null +++ b/app/src/services/api/entityDuplicatesApi.ts @@ -0,0 +1,29 @@ +/** + * RPC facade for Duplicate Entity Detection. + * + * 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 clustering to the pure engine. Read-only — nothing is persisted + * and the stored entity names are never mutated. + */ +import debug from 'debug'; + +import { computeEntityDuplicates, type DuplicateReport } from '../../lib/memory/entityDuplicates'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('entity-duplicates:api'); + +/** Fetch the triples for a namespace (or all) and detect duplicate-entity clusters. */ +export async function loadEntityDuplicates(namespace?: string): Promise { + const relations = await memoryGraphQuery(namespace); + log('loadEntityDuplicates namespace=%s relations=%d', namespace ?? '(all)', relations.length); + return computeEntityDuplicates(relations); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const entityDuplicatesApi = { loadEntityDuplicates, loadNamespaces }; From b438c66f2ef30a036f5a93fbbfeb5a4d5b738aa8 Mon Sep 17 00:00:00 2001 From: Aashir Athar Date: Fri, 29 May 2026 21:41:17 +0500 Subject: [PATCH 2/3] fix(entity-duplicates): add missing namespace selector i18n keys Address CodeRabbit: EntityDuplicatesTab references entityDuplicates.namespaceLabel / namespaceAll but they were never defined. Add both to en.ts + the locale chunks. Co-Authored-By: Claude Opus 4.7 --- app/src/lib/i18n/ar.ts | 19 +++++++++++++++++++ app/src/lib/i18n/bn.ts | 21 +++++++++++++++++++++ app/src/lib/i18n/de.ts | 21 +++++++++++++++++++++ app/src/lib/i18n/en.ts | 4 +++- app/src/lib/i18n/es.ts | 21 +++++++++++++++++++++ app/src/lib/i18n/fr.ts | 21 +++++++++++++++++++++ app/src/lib/i18n/hi.ts | 20 ++++++++++++++++++++ app/src/lib/i18n/id.ts | 21 +++++++++++++++++++++ app/src/lib/i18n/it.ts | 20 ++++++++++++++++++++ app/src/lib/i18n/ko.ts | 20 ++++++++++++++++++++ app/src/lib/i18n/pl.ts | 20 ++++++++++++++++++++ app/src/lib/i18n/pt.ts | 21 +++++++++++++++++++++ app/src/lib/i18n/ru.ts | 21 +++++++++++++++++++++ app/src/lib/i18n/zh-CN.ts | 19 +++++++++++++++++++ 14 files changed, 268 insertions(+), 1 deletion(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index d5e578503b..a575cd2d87 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4350,6 +4350,25 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'رفض التخزين المحلي', 'pages.settings.account.security': 'الأمان', 'pages.settings.account.securityDesc': 'وضع تخزين الأسرار وحالة سلسلة المفاتيح', + 'entityDuplicates.allClean': 'لم تُكتشف إملاءات مكررة — تبدو كياناتك نظيفة.', + 'entityDuplicates.blankEntity': '(فارغ)', + 'entityDuplicates.empty': 'لا يوجد رسم معرفي بعد.', + 'entityDuplicates.emptyHint': 'كلما سجّل المساعد كيانات، ستظهر هنا الإملاءات المكررة.', + 'entityDuplicates.errorPrefix': 'تعذّر تحميل الرسم البياني:', + 'entityDuplicates.heading': 'كيانات يحتمل أنها مكررة', + 'entityDuplicates.intro': + 'يخزن الرسم البياني الأسماء حرفياً، فقد يتشظى الشيء نفسه عبر إملاءات (حالة الأحرف، التباعد). هذه الكيانات تتطبع للاسم نفسه — تكرارات محتملة تستحق التوفيق.', + 'entityDuplicates.loading': 'البحث عن التكرارات…', + 'entityDuplicates.metricAffected': 'المتأثرة', + 'entityDuplicates.metricClusters': 'مجموعات التكرار', + 'entityDuplicates.metricEntities': 'الكيانات', + 'entityDuplicates.namespaceAll': 'كل مساحات الأسماء', + 'entityDuplicates.namespaceLabel': 'مساحة الأسماء', + 'entityDuplicates.retry': 'إعادة المحاولة', + 'entityDuplicates.title': 'الكيانات المكررة', + 'entityDuplicates.truncated': 'عرض {shown} من أصل {total} مجموعة تكرار.', + 'entityDuplicates.variantTitle': '{degree} اتصالات', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index fb196b753d..f7a7544293 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4427,6 +4427,27 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'স্থানীয় সঞ্চয়স্থান প্রত্যাখ্যান করুন', 'pages.settings.account.security': 'নিরাপত্তা', 'pages.settings.account.securityDesc': 'গোপনীয়তা সঞ্চয়স্থান মোড এবং কিচেন অবস্থা', + 'entityDuplicates.allClean': + 'কোনো ডুপ্লিকেট বানান শনাক্ত হয়নি — আপনার সত্তাগুলি পরিষ্কার দেখায়।', + 'entityDuplicates.blankEntity': '(ফাঁকা)', + 'entityDuplicates.empty': 'এখনও কোনো জ্ঞান গ্রাফ নেই।', + 'entityDuplicates.emptyHint': + 'সহকারী যখন সত্তা রেকর্ড করে, যেকোনো ডুপ্লিকেট বানান এখানে উঠে আসবে।', + 'entityDuplicates.errorPrefix': 'গ্রাফ লোড করা যায়নি:', + 'entityDuplicates.heading': 'সম্ভাব্য ডুপ্লিকেট সত্তা', + 'entityDuplicates.intro': + 'গ্রাফ নাম আক্ষরিকভাবে সংরক্ষণ করে, তাই একই জিনিস বানান (কেস, ব্যবধান) জুড়ে খণ্ডিত হতে পারে। এই সত্তাগুলি একই নামে স্বাভাবিক হয় — সম্ভাব্য ডুপ্লিকেট মিলিয়ে নেওয়ার যোগ্য।', + 'entityDuplicates.loading': 'ডুপ্লিকেট স্ক্যান করা হচ্ছে…', + 'entityDuplicates.metricAffected': 'প্রভাবিত', + 'entityDuplicates.metricClusters': 'ডুপ্লিকেট সেট', + 'entityDuplicates.metricEntities': 'সত্তা', + 'entityDuplicates.namespaceAll': 'সমস্ত নেমস্পেস', + 'entityDuplicates.namespaceLabel': 'নেমস্পেস', + 'entityDuplicates.retry': 'পুনরায় চেষ্টা', + 'entityDuplicates.title': 'ডুপ্লিকেট সত্তা', + 'entityDuplicates.truncated': '{total} ডুপ্লিকেট সেটের মধ্যে {shown} দেখাচ্ছি।', + 'entityDuplicates.variantTitle': '{degree} টি সংযোগ', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4b30e1572b..bf27212ac6 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4543,6 +4543,27 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Lokalen Speicher ablehnen', 'pages.settings.account.security': 'Sicherheit', 'pages.settings.account.securityDesc': 'Geheimnisspeicher-Modus und Schlüsselbund-Status', + 'entityDuplicates.allClean': + 'Keine doppelten Schreibweisen erkannt — Ihre Entitäten sehen sauber aus.', + 'entityDuplicates.blankEntity': '(leer)', + 'entityDuplicates.empty': 'Noch kein Wissensgraph.', + 'entityDuplicates.emptyHint': + 'Während der Assistent Entitäten erfasst, erscheinen hier doppelte Schreibweisen.', + 'entityDuplicates.errorPrefix': 'Graph konnte nicht geladen werden:', + 'entityDuplicates.heading': 'Wahrscheinlich doppelte Entitäten', + 'entityDuplicates.intro': + 'Der Graph speichert Namen wörtlich, sodass dieselbe Sache über Schreibweisen (Groß-/Kleinschreibung, Leerzeichen) fragmentieren kann. Diese Entitäten normalisieren sich zum gleichen Namen — wahrscheinliche Duplikate, die abzugleichen sind.', + 'entityDuplicates.loading': 'Suche nach Duplikaten…', + 'entityDuplicates.metricAffected': 'Betroffen', + 'entityDuplicates.metricClusters': 'Duplikat-Mengen', + 'entityDuplicates.metricEntities': 'Entitäten', + 'entityDuplicates.namespaceAll': 'Alle Namensräume', + 'entityDuplicates.namespaceLabel': 'Namensraum', + 'entityDuplicates.retry': 'Wiederholen', + 'entityDuplicates.title': 'Doppelte Entitäten', + 'entityDuplicates.truncated': 'Zeige {shown} von {total} Duplikat-Mengen.', + 'entityDuplicates.variantTitle': '{degree} Verbindungen', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a0b48d0cff..bceab2b7cd 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -376,6 +376,8 @@ const en: TranslationMap = { 'entityDuplicates.loading': 'Scanning for duplicates…', 'entityDuplicates.errorPrefix': 'Could not load the graph:', 'entityDuplicates.retry': 'Retry', + 'entityDuplicates.namespaceLabel': 'Namespace', + 'entityDuplicates.namespaceAll': 'All namespaces', 'entityDuplicates.empty': 'No knowledge graph yet.', 'entityDuplicates.emptyHint': 'As the assistant records entities, any duplicate spellings will surface here.', @@ -2518,7 +2520,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 6feea2220d..9f1811f7d6 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4509,6 +4509,27 @@ 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', + 'entityDuplicates.allClean': + 'No se detectaron ortografías duplicadas — sus entidades se ven limpias.', + 'entityDuplicates.blankEntity': '(en blanco)', + 'entityDuplicates.empty': 'Aún no hay grafo de conocimiento.', + 'entityDuplicates.emptyHint': + 'A medida que el asistente registra entidades, las ortografías duplicadas aparecerán aquí.', + 'entityDuplicates.errorPrefix': 'No se pudo cargar el grafo:', + 'entityDuplicates.heading': 'Entidades probablemente duplicadas', + 'entityDuplicates.intro': + 'El grafo almacena nombres literalmente, por lo que la misma cosa puede fragmentarse a través de ortografías (mayúsculas, espaciado). Estas entidades se normalizan al mismo nombre — probables duplicados que merecen reconciliación.', + 'entityDuplicates.loading': 'Buscando duplicados…', + 'entityDuplicates.metricAffected': 'Afectadas', + 'entityDuplicates.metricClusters': 'Conjuntos de duplicados', + 'entityDuplicates.metricEntities': 'Entidades', + 'entityDuplicates.namespaceAll': 'Todos los espacios de nombres', + 'entityDuplicates.namespaceLabel': 'Espacio de nombres', + 'entityDuplicates.retry': 'Reintentar', + 'entityDuplicates.title': 'Entidades duplicadas', + 'entityDuplicates.truncated': 'Mostrando {shown} de {total} conjuntos de duplicados.', + 'entityDuplicates.variantTitle': '{degree} conexiones', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e3b4395ada..7e05fd748d 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4524,6 +4524,27 @@ 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', + 'entityDuplicates.allClean': + 'Aucune orthographe en doublon détectée — vos entités semblent propres.', + 'entityDuplicates.blankEntity': '(vide)', + 'entityDuplicates.empty': 'Pas encore de graphe de connaissances.', + 'entityDuplicates.emptyHint': + "À mesure que l'assistant enregistre des entités, les orthographes en doublon apparaîtront ici.", + 'entityDuplicates.errorPrefix': 'Impossible de charger le graphe :', + 'entityDuplicates.heading': 'Entités probablement en doublon', + 'entityDuplicates.intro': + 'Le graphe stocke les noms tels quels, donc la même chose peut se fragmenter à travers les orthographes (casse, espacement). Ces entités se normalisent au même nom — probablement des doublons à réconcilier.', + 'entityDuplicates.loading': 'Recherche de doublons…', + 'entityDuplicates.metricAffected': 'Concernés', + 'entityDuplicates.metricClusters': 'Ensembles de doublons', + 'entityDuplicates.metricEntities': 'Entités', + 'entityDuplicates.namespaceAll': 'Tous les espaces de noms', + 'entityDuplicates.namespaceLabel': 'Espace de noms', + 'entityDuplicates.retry': 'Réessayer', + 'entityDuplicates.title': 'Entités en doublon', + 'entityDuplicates.truncated': 'Affichage de {shown} sur {total} ensembles de doublons.', + 'entityDuplicates.variantTitle': '{degree} connexions', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index b63c812398..4dc77ef451 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4434,6 +4434,26 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'स्थानीय भंडारण अस्वीकार करें', 'pages.settings.account.security': 'सुरक्षा', 'pages.settings.account.securityDesc': 'रहस्य भंडारण मोड और कीचेन स्थिति', + 'entityDuplicates.allClean': 'कोई डुप्लिकेट वर्तनी नहीं मिली — आपकी इकाइयाँ साफ दिखती हैं।', + 'entityDuplicates.blankEntity': '(रिक्त)', + 'entityDuplicates.empty': 'अभी कोई नॉलेज ग्राफ नहीं।', + 'entityDuplicates.emptyHint': + 'जैसे-जैसे सहायक इकाइयाँ दर्ज करता है, डुप्लिकेट वर्तनी यहाँ उभरेगी।', + 'entityDuplicates.errorPrefix': 'ग्राफ लोड नहीं हो सका:', + 'entityDuplicates.heading': 'संभावित डुप्लिकेट इकाइयाँ', + 'entityDuplicates.intro': + 'ग्राफ नाम शब्दशः संग्रहीत करता है, इसलिए एक ही चीज़ वर्तनी (केस, स्पेसिंग) में बँट सकती है। ये इकाइयाँ एक ही नाम में सामान्य होती हैं — संभावित डुप्लिकेट जो मेल करने योग्य हैं।', + 'entityDuplicates.loading': 'डुप्लिकेट के लिए स्कैन हो रहा है…', + 'entityDuplicates.metricAffected': 'प्रभावित', + 'entityDuplicates.metricClusters': 'डुप्लिकेट सेट', + 'entityDuplicates.metricEntities': 'इकाइयाँ', + 'entityDuplicates.namespaceAll': 'सभी नेमस्पेस', + 'entityDuplicates.namespaceLabel': 'नेमस्पेस', + 'entityDuplicates.retry': 'पुनः प्रयास', + 'entityDuplicates.title': 'डुप्लिकेट इकाइयाँ', + 'entityDuplicates.truncated': '{total} डुप्लिकेट सेट में से {shown} दिखा रहे हैं।', + 'entityDuplicates.variantTitle': '{degree} कनेक्शन', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index b83ee91377..55bad29b34 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4443,6 +4443,27 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Tolak penyimpanan lokal', 'pages.settings.account.security': 'Keamanan', 'pages.settings.account.securityDesc': 'Mode penyimpanan rahasia dan status keychain', + 'entityDuplicates.allClean': + 'Tidak ada ejaan duplikat terdeteksi — entitas Anda terlihat bersih.', + 'entityDuplicates.blankEntity': '(kosong)', + 'entityDuplicates.empty': 'Belum ada graf pengetahuan.', + 'entityDuplicates.emptyHint': + 'Saat asisten mencatat entitas, ejaan duplikat akan muncul di sini.', + 'entityDuplicates.errorPrefix': 'Tidak dapat memuat graf:', + 'entityDuplicates.heading': 'Entitas yang kemungkinan duplikat', + 'entityDuplicates.intro': + 'Graf menyimpan nama secara harfiah, sehingga hal yang sama dapat terpecah di antara ejaan (kapital, spasi). Entitas-entitas ini menormalisasi ke nama yang sama — kemungkinan duplikat yang layak direkonsiliasi.', + 'entityDuplicates.loading': 'Memindai duplikat…', + 'entityDuplicates.metricAffected': 'Terpengaruh', + 'entityDuplicates.metricClusters': 'Set duplikat', + 'entityDuplicates.metricEntities': 'Entitas', + 'entityDuplicates.namespaceAll': 'Semua ruang nama', + 'entityDuplicates.namespaceLabel': 'Ruang nama', + 'entityDuplicates.retry': 'Coba lagi', + 'entityDuplicates.title': 'Entitas Duplikat', + 'entityDuplicates.truncated': 'Menampilkan {shown} dari {total} set duplikat.', + 'entityDuplicates.variantTitle': '{degree} koneksi', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 2426c1472c..7a0b149dd1 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4501,6 +4501,26 @@ 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', + 'entityDuplicates.allClean': 'Nessuna grafia duplicata rilevata — le tue entità sembrano pulite.', + 'entityDuplicates.blankEntity': '(vuoto)', + 'entityDuplicates.empty': 'Ancora nessun grafo della conoscenza.', + 'entityDuplicates.emptyHint': + "Man mano che l'assistente registra entità, qui appariranno le grafie duplicate.", + 'entityDuplicates.errorPrefix': 'Impossibile caricare il grafo:', + 'entityDuplicates.heading': 'Entità probabilmente duplicate', + 'entityDuplicates.intro': + 'Il grafo memorizza i nomi letteralmente, quindi la stessa cosa può frammentarsi tra grafie diverse (maiuscole, spaziatura). Queste entità si normalizzano allo stesso nome — probabili duplicati da riconciliare.', + 'entityDuplicates.loading': 'Scansione duplicati…', + 'entityDuplicates.metricAffected': 'Coinvolte', + 'entityDuplicates.metricClusters': 'Insiemi di duplicati', + 'entityDuplicates.metricEntities': 'Entità', + 'entityDuplicates.namespaceAll': 'Tutti gli spazi dei nomi', + 'entityDuplicates.namespaceLabel': 'Spazio dei nomi', + 'entityDuplicates.retry': 'Riprova', + 'entityDuplicates.title': 'Entità duplicate', + 'entityDuplicates.truncated': 'Mostrando {shown} di {total} insiemi di duplicati.', + 'entityDuplicates.variantTitle': '{degree} connessioni', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ec68b50ab4..c8d95daf42 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4393,6 +4393,26 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '로컬 저장소 거부', 'pages.settings.account.security': '보안', 'pages.settings.account.securityDesc': '비밀 저장 모드 및 키체인 상태', + 'entityDuplicates.allClean': '중복 철자가 감지되지 않았습니다 — 엔티티가 깨끗해 보입니다.', + 'entityDuplicates.blankEntity': '(비어 있음)', + 'entityDuplicates.empty': '아직 지식 그래프가 없습니다.', + 'entityDuplicates.emptyHint': + '어시스턴트가 엔티티를 기록함에 따라, 중복 철자가 여기에 나타납니다.', + 'entityDuplicates.errorPrefix': '그래프를 불러올 수 없습니다:', + 'entityDuplicates.heading': '중복 가능성 있는 엔티티', + 'entityDuplicates.intro': + '그래프는 이름을 그대로 저장하므로 같은 것이 철자(대소문자, 간격)에 따라 분할될 수 있습니다. 이 엔티티들은 같은 이름으로 정규화됩니다 — 조정할 가치가 있는 중복일 가능성이 있습니다.', + 'entityDuplicates.loading': '중복 스캔 중…', + 'entityDuplicates.metricAffected': '영향받음', + 'entityDuplicates.metricClusters': '중복 집합', + 'entityDuplicates.metricEntities': '엔티티', + 'entityDuplicates.namespaceAll': '모든 네임스페이스', + 'entityDuplicates.namespaceLabel': '네임스페이스', + 'entityDuplicates.retry': '다시 시도', + 'entityDuplicates.title': '중복 엔티티', + 'entityDuplicates.truncated': '{total}개 중복 집합 중 {shown}개 표시 중.', + 'entityDuplicates.variantTitle': '{degree}개 연결', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index b80e01d0d4..9f8cad40c8 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4501,6 +4501,26 @@ 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', + 'entityDuplicates.allClean': 'Nie wykryto zduplikowanych pisowni — twoje encje wyglądają czysto.', + 'entityDuplicates.blankEntity': '(puste)', + 'entityDuplicates.empty': 'Jeszcze brak grafu wiedzy.', + 'entityDuplicates.emptyHint': + 'Gdy asystent zapisuje encje, zduplikowane pisownie pojawią się tutaj.', + 'entityDuplicates.errorPrefix': 'Nie udało się załadować grafu:', + 'entityDuplicates.heading': 'Prawdopodobnie zduplikowane encje', + 'entityDuplicates.intro': + 'Graf przechowuje nazwy dosłownie, więc ta sama rzecz może rozpaść się przez różne pisownie (wielkość liter, spacje). Te encje normalizują się do tej samej nazwy — prawdopodobne duplikaty warte uzgodnienia.', + 'entityDuplicates.loading': 'Skanowanie duplikatów…', + 'entityDuplicates.metricAffected': 'Dotknięte', + 'entityDuplicates.metricClusters': 'Zestawy duplikatów', + 'entityDuplicates.metricEntities': 'Encje', + 'entityDuplicates.namespaceAll': 'Wszystkie przestrzenie nazw', + 'entityDuplicates.namespaceLabel': 'Przestrzeń nazw', + 'entityDuplicates.retry': 'Spróbuj ponownie', + 'entityDuplicates.title': 'Zduplikowane encje', + 'entityDuplicates.truncated': 'Pokazuję {shown} z {total} zestawów duplikatów.', + 'entityDuplicates.variantTitle': '{degree} połączeń', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 75c601bd2f..3acc123a87 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4498,6 +4498,27 @@ 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', + 'entityDuplicates.allClean': + 'Nenhuma grafia duplicada detectada — suas entidades parecem limpas.', + 'entityDuplicates.blankEntity': '(vazio)', + 'entityDuplicates.empty': 'Ainda sem grafo de conhecimento.', + 'entityDuplicates.emptyHint': + 'À medida que o assistente registra entidades, grafias duplicadas aparecerão aqui.', + 'entityDuplicates.errorPrefix': 'Não foi possível carregar o grafo:', + 'entityDuplicates.heading': 'Entidades provavelmente duplicadas', + 'entityDuplicates.intro': + 'O grafo armazena nomes literalmente, então a mesma coisa pode se fragmentar entre grafias (caixa, espaçamento). Estas entidades normalizam para o mesmo nome — prováveis duplicados que valem a pena reconciliar.', + 'entityDuplicates.loading': 'Buscando duplicados…', + 'entityDuplicates.metricAffected': 'Afetadas', + 'entityDuplicates.metricClusters': 'Conjuntos de duplicados', + 'entityDuplicates.metricEntities': 'Entidades', + 'entityDuplicates.namespaceAll': 'Todos os espaços de nomes', + 'entityDuplicates.namespaceLabel': 'Espaço de nomes', + 'entityDuplicates.retry': 'Tentar novamente', + 'entityDuplicates.title': 'Entidades duplicadas', + 'entityDuplicates.truncated': 'Mostrando {shown} de {total} conjuntos de duplicados.', + 'entityDuplicates.variantTitle': '{degree} conexões', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 592a691e0c..51502d7f40 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4469,6 +4469,27 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Отклонить локальное хранилище', 'pages.settings.account.security': 'Безопасность', 'pages.settings.account.securityDesc': 'Режим хранения секретов и статус связки ключей', + 'entityDuplicates.allClean': + 'Дублирующихся написаний не обнаружено — ваши сущности выглядят чисто.', + 'entityDuplicates.blankEntity': '(пусто)', + 'entityDuplicates.empty': 'Пока нет графа знаний.', + 'entityDuplicates.emptyHint': + 'По мере того как ассистент фиксирует сущности, здесь появятся дублирующиеся написания.', + 'entityDuplicates.errorPrefix': 'Не удалось загрузить граф:', + 'entityDuplicates.heading': 'Вероятные дубликаты сущностей', + 'entityDuplicates.intro': + 'Граф хранит имена дословно, поэтому одно и то же может разделиться по написаниям (регистр, пробелы). Эти сущности нормализуются к одному имени — вероятные дубликаты, которые стоит согласовать.', + 'entityDuplicates.loading': 'Поиск дубликатов…', + 'entityDuplicates.metricAffected': 'Затронутые', + 'entityDuplicates.metricClusters': 'Группы дубликатов', + 'entityDuplicates.metricEntities': 'Сущности', + 'entityDuplicates.namespaceAll': 'Все пространства имён', + 'entityDuplicates.namespaceLabel': 'Пространство имён', + 'entityDuplicates.retry': 'Повторить', + 'entityDuplicates.title': 'Дубликаты сущностей', + 'entityDuplicates.truncated': 'Показано {shown} из {total} групп дубликатов.', + 'entityDuplicates.variantTitle': '{degree} связей', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 6169e9a647..24ddb61e24 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4214,6 +4214,25 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '拒绝本地存储', 'pages.settings.account.security': '安全', 'pages.settings.account.securityDesc': '密钥存储模式和密钥链状态', + 'entityDuplicates.allClean': '未检测到重复拼写——你的实体看起来很干净。', + 'entityDuplicates.blankEntity': '(空白)', + 'entityDuplicates.empty': '暂无知识图。', + 'entityDuplicates.emptyHint': '随着助手记录实体,任何重复拼写将在此显现。', + 'entityDuplicates.errorPrefix': '无法加载图:', + 'entityDuplicates.heading': '可能重复的实体', + 'entityDuplicates.intro': + '图按原样存储名称,因此同一事物可能因拼写(大小写、空格)而分裂。这些实体归一化为相同名称——很可能是值得合并的重复项。', + 'entityDuplicates.loading': '正在扫描重复项…', + 'entityDuplicates.metricAffected': '受影响', + 'entityDuplicates.metricClusters': '重复集合', + 'entityDuplicates.metricEntities': '实体', + 'entityDuplicates.namespaceAll': '所有命名空间', + 'entityDuplicates.namespaceLabel': '命名空间', + 'entityDuplicates.retry': '重试', + 'entityDuplicates.title': '重复实体', + 'entityDuplicates.truncated': '显示 {total} 个重复集合中的 {shown} 个。', + 'entityDuplicates.variantTitle': '{degree} 个连接', + 'memory.tab.duplicates': 'Duplicates', }; export default messages; From 06c970ddb328dcc65887285cbaee5e0dafdfd155 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 1 Jun 2026 16:27:30 +0500 Subject: [PATCH 3/3] fix(entity-duplicates): translate tab labels --- app/src/lib/i18n/ar.ts | 2 +- app/src/lib/i18n/bn.ts | 2 +- app/src/lib/i18n/de.ts | 2 +- app/src/lib/i18n/es.ts | 2 +- app/src/lib/i18n/fr.ts | 2 +- app/src/lib/i18n/hi.ts | 2 +- app/src/lib/i18n/id.ts | 2 +- app/src/lib/i18n/it.ts | 2 +- app/src/lib/i18n/ko.ts | 2 +- app/src/lib/i18n/pl.ts | 2 +- app/src/lib/i18n/pt.ts | 2 +- app/src/lib/i18n/ru.ts | 2 +- app/src/lib/i18n/zh-CN.ts | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 09acebdc1b..60cba3c256 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4380,7 +4380,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'الكيانات المكررة', 'entityDuplicates.truncated': 'عرض {shown} من أصل {total} مجموعة تكرار.', 'entityDuplicates.variantTitle': '{degree} اتصالات', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'التكرارات', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 52084545ae..6177785acc 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4459,7 +4459,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'ডুপ্লিকেট সত্তা', 'entityDuplicates.truncated': '{total} ডুপ্লিকেট সেটের মধ্যে {shown} দেখাচ্ছি।', 'entityDuplicates.variantTitle': '{degree} টি সংযোগ', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'সদৃশ', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index f2813721f5..13d41653ce 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4576,7 +4576,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'Doppelte Entitäten', 'entityDuplicates.truncated': 'Zeige {shown} von {total} Duplikat-Mengen.', 'entityDuplicates.variantTitle': '{degree} Verbindungen', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'Duplikate', }; export default messages; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index a2ccde16f6..20f3e20e47 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4542,7 +4542,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'Entidades duplicadas', 'entityDuplicates.truncated': 'Mostrando {shown} de {total} conjuntos de duplicados.', 'entityDuplicates.variantTitle': '{degree} conexiones', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'Duplicados', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 583c7b5f5c..eba99b11ed 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4557,7 +4557,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'Entités en doublon', 'entityDuplicates.truncated': 'Affichage de {shown} sur {total} ensembles de doublons.', 'entityDuplicates.variantTitle': '{degree} connexions', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'Doublons', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index af10333fc3..928ca8de0a 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4465,7 +4465,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'डुप्लिकेट इकाइयाँ', 'entityDuplicates.truncated': '{total} डुप्लिकेट सेट में से {shown} दिखा रहे हैं।', 'entityDuplicates.variantTitle': '{degree} कनेक्शन', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'डुप्लिकेट', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 8bcba9594f..9038817f52 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4476,7 +4476,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'Entitas Duplikat', 'entityDuplicates.truncated': 'Menampilkan {shown} dari {total} set duplikat.', 'entityDuplicates.variantTitle': '{degree} koneksi', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'Duplikat', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 665a40768a..5c6baf6ed6 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4533,7 +4533,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'Entità duplicate', 'entityDuplicates.truncated': 'Mostrando {shown} di {total} insiemi di duplicati.', 'entityDuplicates.variantTitle': '{degree} connessioni', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'Duplicati', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 261cf368e5..23850171e9 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4424,7 +4424,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': '중복 엔티티', 'entityDuplicates.truncated': '{total}개 중복 집합 중 {shown}개 표시 중.', 'entityDuplicates.variantTitle': '{degree}개 연결', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': '중복', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 1073505752..834219900b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4532,7 +4532,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'Zduplikowane encje', 'entityDuplicates.truncated': 'Pokazuję {shown} z {total} zestawów duplikatów.', 'entityDuplicates.variantTitle': '{degree} połączeń', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'Duplikaty', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 6a330f731f..179f6b8f89 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4531,7 +4531,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'Entidades duplicadas', 'entityDuplicates.truncated': 'Mostrando {shown} de {total} conjuntos de duplicados.', 'entityDuplicates.variantTitle': '{degree} conexões', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'Duplicados', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 9412e4d964..f0c85ea356 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4502,7 +4502,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': 'Дубликаты сущностей', 'entityDuplicates.truncated': 'Показано {shown} из {total} групп дубликатов.', 'entityDuplicates.variantTitle': '{degree} связей', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': 'Дубликаты', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index b745ad8428..34202c95ce 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4244,7 +4244,7 @@ const messages: TranslationMap = { 'entityDuplicates.title': '重复实体', 'entityDuplicates.truncated': '显示 {total} 个重复集合中的 {shown} 个。', 'entityDuplicates.variantTitle': '{degree} 个连接', - 'memory.tab.duplicates': 'Duplicates', + 'memory.tab.duplicates': '重复项', }; export default messages;