From 4d3b63857fb364e76cda612e158d3be5141337ba Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 31 Mar 2026 23:31:06 +0530 Subject: [PATCH] feat(memory): connect graph query and doc ingest APIs (#124) * feat(memory): connect graph query and doc ingest APIs to frontend Wire up memory.graph.query and memory.doc.ingest RPC endpoints and integrate graph relations into the MemoryWorkspace UI, replacing backend-only entity counts with local graph store data. Co-Authored-By: Claude Opus 4.6 (1M context) * style: fix Prettier formatting in MemoryWorkspace and tauriCommands Co-Authored-By: Claude Opus 4.6 (1M context) * test: add unit tests for memory graph query, doc ingest, and dispatch routing Cover the new graph query and doc ingest APIs added in this PR: - Rust dispatch tests: routing, param validation, unknown method fallthrough - Frontend tauriCommands tests: Tauri guard, RPC forwarding for memoryGraphQuery/memoryDocIngest - MemoryWorkspace component tests: graph relations rendering, evidence badges, empty states Co-Authored-By: Claude Opus 4.6 (1M context) * style: fix Prettier and cargo fmt formatting in test files Co-Authored-By: Claude Opus 4.6 (1M context) * style: fix Prettier formatting in tauriCommandsMemory test Co-Authored-By: Claude Opus 4.6 (1M context) * fix: merge duplicate tauriCommands import in MemoryWorkspace Co-Authored-By: Claude Opus 4.6 (1M context) * style: fix Prettier formatting in MemoryWorkspace Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: sanil jain Co-authored-by: Claude Opus 4.6 (1M context) --- .../intelligence/MemoryWorkspace.tsx | 93 +++++++++- .../__tests__/MemoryWorkspace.test.tsx | 160 ++++++++++++++++++ .../__tests__/tauriCommandsMemory.test.ts | 116 +++++++++++++ app/src/utils/tauriCommands.ts | 46 +++++ src/openhuman/memory/ops.rs | 56 +++++- src/rpc/dispatch.rs | 69 ++++++++ 6 files changed, 534 insertions(+), 6 deletions(-) create mode 100644 app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx create mode 100644 app/src/utils/__tests__/tauriCommandsMemory.test.ts diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index 419d30554..3be1bdc09 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -6,8 +6,10 @@ import { aiListMemoryFiles, aiReadMemoryFile, aiWriteMemoryFile, + type GraphRelation, isTauri, memoryDeleteDocument, + memoryGraphQuery, memoryListDocuments, memoryListNamespaces, memoryQueryNamespace, @@ -158,6 +160,9 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { const [memoryNote, setMemoryNote] = useState(''); const [memoryNoteSaving, setMemoryNoteSaving] = useState(false); + const [graphRelations, setGraphRelations] = useState([]); + const [graphRelationsLoading, setGraphRelationsLoading] = useState(false); + const loadWorkspace = useCallback(async () => { if (!isTauri()) return; @@ -171,6 +176,17 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { aiListMemoryFiles('memory'), ]); + // Load graph relations from local store + setGraphRelationsLoading(true); + try { + const relations = await memoryGraphQuery(); + setGraphRelations(relations); + } catch { + setGraphRelations([]); + } finally { + setGraphRelationsLoading(false); + } + const docs = normalizeMemoryDocuments(documentsPayload); const combinedFiles = ['memory.md', ...memoryDirFiles.map(file => `memory/${file}`)]; @@ -342,13 +358,33 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { value: `${item.count} docs`, })); - const entityNodes = Object.entries(entities || {}) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([type, count]) => ({ label: type, value: `${count} entities` })); + // Build entity nodes from local graph store relations + const entityCounts = new Map(); + for (const rel of graphRelations) { + const subjectType = + (rel.attrs?.entity_types as Record | undefined)?.subject ?? 'entity'; + const objectType = + (rel.attrs?.entity_types as Record | undefined)?.object ?? 'entity'; + entityCounts.set(subjectType, (entityCounts.get(subjectType) ?? 0) + 1); + entityCounts.set(objectType, (entityCounts.get(objectType) ?? 0) + 1); + } + + let entityNodes: MemoryNode[]; + if (entityCounts.size > 0) { + entityNodes = Array.from(entityCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([type, count]) => ({ label: type, value: `${count} entities` })); + } else { + // Fallback to backend API entity counts + entityNodes = Object.entries(entities || {}) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([type, count]) => ({ label: type, value: `${count} entities` })); + } return [...namespaceNodes, ...entityNodes].slice(0, 6); - }, [entities, topNamespaces]); + }, [entities, graphRelations, topNamespaces]); return (
@@ -464,6 +500,52 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
Nodes show top namespaces and entity buckets connected to your core profile.
+ +
+
+
+ Graph relations{' '} + {graphRelations.length > 0 && ( + ({graphRelations.length}) + )} +
+ {graphRelationsLoading && ( +
Loading...
+ )} +
+
+ {graphRelations.length === 0 ? ( +
+ {graphRelationsLoading + ? 'Loading graph...' + : 'No relations yet. Ingest documents to populate the graph.'} +
+ ) : ( + graphRelations.slice(0, 20).map((rel, idx) => ( +
+ + {rel.subject} + + + + {rel.predicate} + + + + {rel.object} + + {rel.evidenceCount > 1 && ( + + x{rel.evidenceCount} + + )} +
+ )) + )} +
+
@@ -635,6 +717,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
Sessions: {sessions ? formatNumber(sessions.total) : '--'} · Token volume:{' '} {sessions ? formatNumber(sessions.totalTokens) : '--'} + {graphRelations.length > 0 && <> · Relations: {formatNumber(graphRelations.length)}} {entities && <> · Entity buckets: {formatNumber(Object.keys(entities).length)}}
diff --git a/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx new file mode 100644 index 000000000..837c44653 --- /dev/null +++ b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx @@ -0,0 +1,160 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import { MemoryWorkspace } from '../MemoryWorkspace'; + +// Mock useIntelligenceStats — the hook used by MemoryWorkspace +vi.mock('../../../hooks/useIntelligenceStats', () => ({ + useIntelligenceStats: () => ({ + sessions: { total: 5, totalTokens: 1200 }, + memoryFiles: 3, + entities: { contact: 2, message: 10 }, + isLoading: false, + refetch: vi.fn(), + }), +})); + +// Override the global tauriCommands mock from setup.ts with memory-specific stubs +vi.mock('../../../utils/tauriCommands', () => ({ + isTauri: vi.fn(() => true), + memoryListDocuments: vi.fn().mockResolvedValue({ + documents: [ + { documentId: 'doc-1', namespace: 'research', title: 'Paper A' }, + { documentId: 'doc-2', namespace: 'research', title: 'Paper B' }, + ], + }), + memoryListNamespaces: vi.fn().mockResolvedValue(['research', 'conversations']), + aiListMemoryFiles: vi.fn().mockResolvedValue(['2026-03-31.md']), + aiReadMemoryFile: vi.fn().mockResolvedValue('# Memory\nSome content'), + aiWriteMemoryFile: vi.fn().mockResolvedValue(undefined), + memoryDeleteDocument: vi.fn().mockResolvedValue(undefined), + memoryQueryNamespace: vi.fn().mockResolvedValue('query result'), + memoryRecallNamespace: vi.fn().mockResolvedValue('recall result'), + memoryGraphQuery: vi.fn().mockResolvedValue([ + { + namespace: 'research', + subject: 'Alice', + predicate: 'AUTHORED', + object: 'Paper A', + attrs: { entity_types: { subject: 'person', object: 'document' } }, + updatedAt: 1700000000, + evidenceCount: 3, + orderIndex: null, + documentIds: ['doc-1'], + chunkIds: ['doc-1#chunk-1'], + }, + { + namespace: 'research', + subject: 'Bob', + predicate: 'REVIEWED', + object: 'Paper A', + attrs: { entity_types: { subject: 'person', object: 'document' } }, + updatedAt: 1700000001, + evidenceCount: 1, + orderIndex: null, + documentIds: ['doc-1'], + chunkIds: [], + }, + ]), +})); + +describe('MemoryWorkspace', () => { + const onToast = vi.fn(); + + it('renders the Memory Workspace heading', async () => { + renderWithProviders(); + expect(screen.getByText('Memory Workspace')).toBeInTheDocument(); + }); + + it('displays graph relations after loading', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + expect(screen.getByText('AUTHORED')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('REVIEWED')).toBeInTheDocument(); + // "Paper A" appears in both graph relations and documents list, + // so just verify at least one instance is present + expect(screen.getAllByText('Paper A').length).toBeGreaterThanOrEqual(1); + }); + + it('shows evidence count badge when > 1', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('x3')).toBeInTheDocument(); + }); + + // Bob's relation has evidenceCount 1 — should NOT show a badge + expect(screen.queryByText('x1')).not.toBeInTheDocument(); + }); + + it('shows relations count in footer stats', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/Relations: 2/)).toBeInTheDocument(); + }); + }); + + it('displays Graph relations header with count', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('(2)')).toBeInTheDocument(); + }); + }); + + it('builds entity nodes from graph relation attrs', async () => { + renderWithProviders(); + + // Entity nodes are built from entity_types in attrs + // person appears 2x (Alice and Bob as subject), document appears 2x (Paper A as object) + await waitFor(() => { + // Entity nodes should include counts from relation entity_types + expect(screen.getByText('2 entities')).toBeInTheDocument(); + }); + }); +}); + +describe('MemoryWorkspace – no graph relations', () => { + const onToast = vi.fn(); + + it('shows empty-state message when no relations exist', async () => { + // Override only memoryGraphQuery to return empty + const tauriMod = await import('../../../utils/tauriCommands'); + vi.mocked(tauriMod.memoryGraphQuery).mockResolvedValueOnce([]); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByText('No relations yet. Ingest documents to populate the graph.') + ).toBeInTheDocument(); + }); + }); +}); + +describe('MemoryWorkspace – non-Tauri environment', () => { + const onToast = vi.fn(); + + it('shows Tauri-required warning when not running in Tauri', async () => { + const tauriMod = await import('../../../utils/tauriCommands'); + vi.mocked(tauriMod.isTauri).mockReturnValue(false); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByText('Memory workspace requires the desktop Tauri runtime to load real data.') + ).toBeInTheDocument(); + }); + + // Restore for other tests + vi.mocked(tauriMod.isTauri).mockReturnValue(true); + }); +}); diff --git a/app/src/utils/__tests__/tauriCommandsMemory.test.ts b/app/src/utils/__tests__/tauriCommandsMemory.test.ts new file mode 100644 index 000000000..e66324a1d --- /dev/null +++ b/app/src/utils/__tests__/tauriCommandsMemory.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { memoryDocIngest, memoryGraphQuery } from '../tauriCommands'; + +// The global setup mocks isTauri to return false by default. +// We need to selectively override it for these tests. + +// Re-mock tauriCommands so we can test the actual implementations +// rather than the blanket mock from setup.ts. +vi.mock('../tauriCommands', async () => { + const actual = await vi.importActual>('../tauriCommands'); + return actual; +}); + +// Mock @tauri-apps/api/core — isTauri controls the guard in each function +const mockIsTauri = vi.fn(() => false); +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: () => mockIsTauri() })); + +// Mock callCoreRpc — the underlying transport for all memory commands +const mockCallCoreRpc = vi.fn(); +vi.mock('../../services/coreRpcClient', () => ({ + callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args), +})); + +describe('memoryGraphQuery', () => { + it('throws when not running in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + await expect(memoryGraphQuery()).rejects.toThrow('Not running in Tauri'); + }); + + it('calls core RPC with memory.graph.query method and optional params', async () => { + mockIsTauri.mockReturnValue(true); + const mockRelations = [ + { + namespace: 'team', + subject: 'Alice', + predicate: 'OWNS', + object: 'Atlas', + attrs: {}, + updatedAt: 1700000000, + evidenceCount: 2, + orderIndex: null, + documentIds: ['doc-1'], + chunkIds: ['doc-1#chunk-1'], + }, + ]; + mockCallCoreRpc.mockResolvedValue(mockRelations); + + const result = await memoryGraphQuery('team', 'Alice', 'OWNS'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'memory.graph.query', + params: { namespace: 'team', subject: 'Alice', predicate: 'OWNS' }, + }); + expect(result).toEqual(mockRelations); + }); + + it('passes undefined params when called with no arguments', async () => { + mockIsTauri.mockReturnValue(true); + mockCallCoreRpc.mockResolvedValue([]); + + await memoryGraphQuery(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'memory.graph.query', + params: { namespace: undefined, subject: undefined, predicate: undefined }, + }); + }); +}); + +describe('memoryDocIngest', () => { + it('throws when not running in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + await expect( + memoryDocIngest({ namespace: 'ns', key: 'k', title: 't', content: 'c' }) + ).rejects.toThrow('Not running in Tauri'); + }); + + it('calls core RPC with memory.doc.ingest and forwards all params', async () => { + mockIsTauri.mockReturnValue(true); + const ingestResult = { + document_id: 'doc-123', + entity_count: 3, + relation_count: 2, + chunk_count: 5, + }; + mockCallCoreRpc.mockResolvedValue(ingestResult); + + const params = { + namespace: 'research', + key: 'paper-1', + title: 'Memory Paper', + content: 'Some content about memory systems', + source_type: 'paper', + priority: 'high', + tags: ['memory', 'ai'], + metadata: { author: 'Alice' }, + category: 'research', + }; + + const result = await memoryDocIngest(params); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'memory.doc.ingest', params }); + expect(result).toEqual(ingestResult); + }); + + it('sends only required fields when optional fields are omitted', async () => { + mockIsTauri.mockReturnValue(true); + mockCallCoreRpc.mockResolvedValue({}); + + const params = { namespace: 'ns', key: 'k', title: 't', content: 'c' }; + await memoryDocIngest(params); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'memory.doc.ingest', params }); + }); +}); diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts index b45210b85..c0e460b70 100644 --- a/app/src/utils/tauriCommands.ts +++ b/app/src/utils/tauriCommands.ts @@ -267,6 +267,52 @@ export async function memoryRecallNamespace( }); } +export interface GraphRelation { + namespace: string | null; + subject: string; + predicate: string; + object: string; + attrs: Record; + updatedAt: number; + evidenceCount: number; + orderIndex: number | null; + documentIds: string[]; + chunkIds: string[]; +} + +export async function memoryGraphQuery( + namespace?: string, + subject?: string, + predicate?: string +): Promise { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc({ + method: 'memory.graph.query', + params: { namespace, subject, predicate }, + }); +} + +export async function memoryDocIngest(params: { + namespace: string; + key: string; + title: string; + content: string; + source_type?: string; + priority?: string; + tags?: string[]; + metadata?: Record; + category?: string; + session_id?: string; + document_id?: string; +}): Promise { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await callCoreRpc({ method: 'memory.doc.ingest', params }); +} + export async function aiListMemoryFiles(relativeDir = 'memory'): Promise { if (!isTauri()) { throw new Error('Not running in Tauri'); diff --git a/src/openhuman/memory/ops.rs b/src/openhuman/memory/ops.rs index 68b4c15d5..13970ada2 100644 --- a/src/openhuman/memory/ops.rs +++ b/src/openhuman/memory/ops.rs @@ -8,7 +8,8 @@ use crate::openhuman::memory::{ ApiEnvelope, ApiError, ApiMeta, DeleteDocumentRequest, DeleteDocumentResponse, EmptyRequest, ListDocumentsRequest, ListDocumentsResponse, ListMemoryFilesRequest, ListMemoryFilesResponse, ListNamespacesResponse, MemoryClient, MemoryClientRef, MemoryDocumentSummary, - MemoryInitRequest, MemoryInitResponse, MemoryItemKind, MemoryRecallItem, MemoryRetrievalChunk, + MemoryIngestionConfig, MemoryIngestionRequest, MemoryIngestionResult, MemoryInitRequest, + MemoryInitResponse, MemoryItemKind, MemoryRecallItem, MemoryRetrievalChunk, MemoryRetrievalContext, MemoryRetrievalEntity, MemoryRetrievalRelation, NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, PaginationMeta, QueryNamespaceRequest, QueryNamespaceResponse, ReadMemoryFileRequest, ReadMemoryFileResponse, RecallContextRequest, @@ -449,6 +450,30 @@ pub struct PutDocParams { pub document_id: Option, } +#[derive(Debug, Deserialize)] +pub struct IngestDocParams { + pub namespace: String, + pub key: String, + pub title: String, + pub content: String, + #[serde(default = "default_source_type")] + pub source_type: String, + #[serde(default = "default_priority")] + pub priority: String, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub metadata: serde_json::Value, + #[serde(default = "default_category")] + pub category: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub document_id: Option, + #[serde(default)] + pub config: Option, +} + #[derive(Debug, Deserialize)] pub struct NamespaceOnlyParams { pub namespace: String, @@ -560,6 +585,35 @@ pub async fn doc_put(params: PutDocParams) -> Result, S )) } +pub async fn doc_ingest( + params: IngestDocParams, +) -> Result, String> { + let client = active_memory_client().await?; + let result = client + .ingest_doc(MemoryIngestionRequest { + document: NamespaceDocumentInput { + namespace: params.namespace, + key: params.key, + title: params.title, + content: params.content, + source_type: params.source_type, + priority: params.priority, + tags: params.tags, + metadata: params.metadata, + category: params.category, + session_id: params.session_id, + document_id: params.document_id, + }, + config: params.config.unwrap_or_default(), + }) + .await?; + let msg = format!( + "ingested document {} — {} entities, {} relations, {} chunks", + result.document_id, result.entity_count, result.relation_count, result.chunk_count, + ); + Ok(RpcOutcome::single_log(result, &msg)) +} + pub async fn doc_list( params: Option, ) -> Result, String> { diff --git a/src/rpc/dispatch.rs b/src/rpc/dispatch.rs index 7ead363fe..dd3a3216c 100644 --- a/src/rpc/dispatch.rs +++ b/src/rpc/dispatch.rs @@ -119,6 +119,14 @@ pub async fn try_dispatch( .await, ), + "memory.doc.ingest" => Some( + async move { + let payload: crate::openhuman::memory::rpc::IngestDocParams = parse_params(params)?; + rpc_json(crate::openhuman::memory::rpc::doc_ingest(payload).await?) + } + .await, + ), + "memory.doc.list" => Some( async move { let payload: MemoryDocListParams = parse_params(params)?; @@ -216,3 +224,64 @@ pub async fn try_dispatch( _ => None, } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::try_dispatch; + + /// Verify that the dispatcher recognises `memory.doc.ingest` (returns `Some`). + /// The inner handler will fail because no memory client is initialised, + /// but the route being present (not `None`) is what we need to assert. + #[tokio::test] + async fn dispatch_routes_memory_doc_ingest() { + let params = json!({ + "namespace": "test", + "key": "k1", + "title": "Title", + "content": "body" + }); + let result = try_dispatch("memory.doc.ingest", params).await; + assert!( + result.is_some(), + "memory.doc.ingest should be routed by dispatch" + ); + } + + /// Verify that `memory.graph.query` is routed. + #[tokio::test] + async fn dispatch_routes_memory_graph_query() { + let params = json!({}); + let result = try_dispatch("memory.graph.query", params).await; + assert!( + result.is_some(), + "memory.graph.query should be routed by dispatch" + ); + } + + /// Unknown methods must return `None` so callers can fall through. + #[tokio::test] + async fn dispatch_returns_none_for_unknown_method() { + let result = try_dispatch("nonexistent.method", json!({})).await; + assert!(result.is_none(), "unknown methods should return None"); + } + + /// Verify that params deserialization errors surface as `Some(Err(...))`. + #[tokio::test] + async fn dispatch_memory_doc_ingest_rejects_invalid_params() { + // Missing required fields → should be Some(Err) + let result = try_dispatch("memory.doc.ingest", json!({})).await; + assert!(result.is_some()); + let inner = result.unwrap(); + assert!( + inner.is_err(), + "missing required fields should produce a deserialization error" + ); + let err_msg = inner.unwrap_err(); + assert!( + err_msg.contains("invalid params"), + "error should mention invalid params, got: {err_msg}" + ); + } +}