mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -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<GraphRelation[]>([]);
|
||||
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<string, number>();
|
||||
for (const rel of graphRelations) {
|
||||
const subjectType =
|
||||
(rel.attrs?.entity_types as Record<string, string> | undefined)?.subject ?? 'entity';
|
||||
const objectType =
|
||||
(rel.attrs?.entity_types as Record<string, string> | 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 (
|
||||
<section className="glass rounded-2xl p-5 border border-white/10 animate-fade-up">
|
||||
@@ -464,6 +500,52 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
<div className="mt-3 text-xs text-stone-400">
|
||||
Nodes show top namespaces and entity buckets connected to your core profile.
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-xs text-stone-400">
|
||||
Graph relations{' '}
|
||||
{graphRelations.length > 0 && (
|
||||
<span className="text-stone-500">({graphRelations.length})</span>
|
||||
)}
|
||||
</div>
|
||||
{graphRelationsLoading && (
|
||||
<div className="text-[10px] text-stone-500">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 max-h-36 overflow-y-auto pr-1">
|
||||
{graphRelations.length === 0 ? (
|
||||
<div className="text-[11px] text-stone-500">
|
||||
{graphRelationsLoading
|
||||
? 'Loading graph...'
|
||||
: 'No relations yet. Ingest documents to populate the graph.'}
|
||||
</div>
|
||||
) : (
|
||||
graphRelations.slice(0, 20).map((rel, idx) => (
|
||||
<div
|
||||
key={`${rel.subject}-${rel.predicate}-${rel.object}-${idx}`}
|
||||
className="flex items-center gap-1.5 rounded border border-white/5 bg-stone-950/50 px-2 py-1 text-[11px]">
|
||||
<span className="text-primary-300 truncate max-w-20" title={rel.subject}>
|
||||
{rel.subject}
|
||||
</span>
|
||||
<span className="text-stone-500 shrink-0">→</span>
|
||||
<span className="text-amber-300/80 truncate max-w-20" title={rel.predicate}>
|
||||
{rel.predicate}
|
||||
</span>
|
||||
<span className="text-stone-500 shrink-0">→</span>
|
||||
<span className="text-emerald-300/80 truncate max-w-20" title={rel.object}>
|
||||
{rel.object}
|
||||
</span>
|
||||
{rel.evidenceCount > 1 && (
|
||||
<span className="ml-auto text-[10px] text-stone-500 shrink-0">
|
||||
x{rel.evidenceCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4 xl:col-span-2">
|
||||
@@ -635,6 +717,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
<div className="mt-3 text-[11px] text-stone-500">
|
||||
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)}</>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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(<MemoryWorkspace onToast={onToast} />);
|
||||
expect(screen.getByText('Memory Workspace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays graph relations after loading', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
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(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
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(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Relations: 2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays Graph relations header with count', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('(2)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('builds entity nodes from graph relation attrs', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
// 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(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
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(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>>('../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 });
|
||||
});
|
||||
});
|
||||
@@ -267,6 +267,52 @@ export async function memoryRecallNamespace(
|
||||
});
|
||||
}
|
||||
|
||||
export interface GraphRelation {
|
||||
namespace: string | null;
|
||||
subject: string;
|
||||
predicate: string;
|
||||
object: string;
|
||||
attrs: Record<string, unknown>;
|
||||
updatedAt: number;
|
||||
evidenceCount: number;
|
||||
orderIndex: number | null;
|
||||
documentIds: string[];
|
||||
chunkIds: string[];
|
||||
}
|
||||
|
||||
export async function memoryGraphQuery(
|
||||
namespace?: string,
|
||||
subject?: string,
|
||||
predicate?: string
|
||||
): Promise<GraphRelation[]> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<GraphRelation[]>({
|
||||
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<string, unknown>;
|
||||
category?: string;
|
||||
session_id?: string;
|
||||
document_id?: string;
|
||||
}): Promise<unknown> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<unknown>({ method: 'memory.doc.ingest', params });
|
||||
}
|
||||
|
||||
export async function aiListMemoryFiles(relativeDir = 'memory'): Promise<string[]> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: serde_json::Value,
|
||||
#[serde(default = "default_category")]
|
||||
pub category: String,
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub document_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub config: Option<MemoryIngestionConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NamespaceOnlyParams {
|
||||
pub namespace: String,
|
||||
@@ -560,6 +585,35 @@ pub async fn doc_put(params: PutDocParams) -> Result<RpcOutcome<PutDocResult>, S
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn doc_ingest(
|
||||
params: IngestDocParams,
|
||||
) -> Result<RpcOutcome<MemoryIngestionResult>, 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<NamespaceOnlyParams>,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user