mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
* fix: patch whisper-rs-sys for Windows MSVC static CRT (/MT) The upstream whisper-rs-sys builds whisper.cpp via CMake which defaults to /MD (dynamic CRT), but Rust and all other C deps use /MT (static CRT). This causes LNK2038/LNK1169 linker errors on Windows. Patch whisper-rs-sys from tinyhumansai/whisper-rs-sys fork which adds config.static_crt(true) and overrides all per-config CMake flags (Debug/Release/MinSizeRel/RelWithDebInfo) from /MD to /MT. Closes #273 * feat: surface entity types in memory recall/query text context Entity types extracted by GLiNER (person, project, organization, etc.) were stored in graph attrs but not rendered in LLM context text. Relations now display as Alice (PERSON) -[OWNS]-> Atlas (PROJECT) instead of Alice -[OWNS]-> Atlas. Closes #207 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(memory): add entity type UI rendering (#207) - New MemoryTextWithEntities component with colour-coded type badges - MemoryWorkspace + MemoryDebugPanel pass structured entity data - MemoryGraphMap shows entity types below node labels - MemoryInsights shows EntityTypeBadge for subject/object types - tauriCommands returns typed MemoryQueryResult with entities - Updated useConsciousItems and tests for new return types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix cargo fmt in query.rs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use local regex instead of mutating module-level lastIndex Avoids react-hooks/immutability ESLint error by using a non-global regex for the .test() check instead of resetting ENTITY_TYPE_RE.lastIndex. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: sanil jain <jainsanil18@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
sanil jain
Claude Opus 4.6
parent
15ee9220a8
commit
735152cb3f
@@ -11,6 +11,7 @@ interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
namespace: string | null;
|
||||
entityType: string | null;
|
||||
connectionCount: number;
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -50,19 +51,28 @@ function buildGraph(relations: GraphRelation[]): { nodes: GraphNode[]; edges: Gr
|
||||
const cappedRelations = sorted.slice(0, MAX_EDGES);
|
||||
|
||||
// Collect unique entity ids
|
||||
const entitySet = new Map<string, { namespace: string | null; count: number }>();
|
||||
const entitySet = new Map<
|
||||
string,
|
||||
{ namespace: string | null; count: number; entityType: string | null }
|
||||
>();
|
||||
|
||||
for (const r of cappedRelations) {
|
||||
const subKey = r.subject.toLowerCase();
|
||||
const objKey = r.object.toLowerCase();
|
||||
const entityTypes = (r.attrs?.entity_types ?? {}) as Record<string, string>;
|
||||
|
||||
const existing = entitySet.get(subKey);
|
||||
entitySet.set(subKey, { namespace: r.namespace, count: (existing?.count ?? 0) + 1 });
|
||||
entitySet.set(subKey, {
|
||||
namespace: r.namespace,
|
||||
count: (existing?.count ?? 0) + 1,
|
||||
entityType: existing?.entityType ?? entityTypes.subject ?? null,
|
||||
});
|
||||
|
||||
const existingObj = entitySet.get(objKey);
|
||||
entitySet.set(objKey, {
|
||||
namespace: existingObj?.namespace ?? r.namespace,
|
||||
count: (existingObj?.count ?? 0) + 1,
|
||||
entityType: existingObj?.entityType ?? entityTypes.object ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,6 +85,7 @@ function buildGraph(relations: GraphRelation[]): { nodes: GraphNode[]; edges: Gr
|
||||
id,
|
||||
label: id,
|
||||
namespace: info.namespace,
|
||||
entityType: info.entityType,
|
||||
connectionCount: info.count,
|
||||
x: 80 + Math.random() * (WIDTH - 160),
|
||||
y: 80 + Math.random() * (HEIGHT - 160),
|
||||
@@ -334,6 +345,23 @@ export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
|
||||
style={{ pointerEvents: 'none', userSelect: 'none', transition: 'fill 0.15s' }}>
|
||||
{isCenter && node.id !== 'you' ? 'You' : truncate(node.label)}
|
||||
</text>
|
||||
{node.entityType && (
|
||||
<text
|
||||
y={r + 22}
|
||||
textAnchor="middle"
|
||||
fontSize={7}
|
||||
fontWeight={500}
|
||||
letterSpacing="0.04em"
|
||||
fill={isDimmed ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)'}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
textTransform: 'uppercase',
|
||||
transition: 'fill 0.15s',
|
||||
}}>
|
||||
{node.entityType}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -30,6 +30,8 @@ interface InsightItem {
|
||||
evidenceCount: number;
|
||||
namespace: string | null;
|
||||
updatedAt: number;
|
||||
subjectType: string | null;
|
||||
objectType: string | null;
|
||||
}
|
||||
|
||||
const PREDICATE_CATEGORIES: Record<string, InsightCategory> = {
|
||||
@@ -134,6 +136,15 @@ const CATEGORY_CONFIG: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/** Small inline badge that displays an entity type (e.g. "person", "project"). */
|
||||
function EntityTypeBadge({ type }: { type: string }) {
|
||||
return (
|
||||
<span className="inline-block ml-1 px-1 py-px rounded text-[9px] leading-tight font-medium bg-white/8 text-stone-400 border border-white/6 uppercase tracking-wide">
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
|
||||
const [expandedCategory, setExpandedCategory] = useState<InsightCategory | null>(null);
|
||||
|
||||
@@ -143,6 +154,7 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
|
||||
for (const rel of relations) {
|
||||
const category = categorize(rel.predicate);
|
||||
const items = buckets.get(category) ?? [];
|
||||
const entityTypes = (rel.attrs?.entity_types ?? {}) as Record<string, string>;
|
||||
items.push({
|
||||
subject: rel.subject,
|
||||
predicate: rel.predicate,
|
||||
@@ -150,6 +162,8 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
|
||||
evidenceCount: rel.evidenceCount,
|
||||
namespace: rel.namespace,
|
||||
updatedAt: rel.updatedAt,
|
||||
subjectType: entityTypes.subject ?? null,
|
||||
objectType: entityTypes.object ?? null,
|
||||
});
|
||||
buckets.set(category, items);
|
||||
}
|
||||
@@ -264,10 +278,12 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
|
||||
className="text-white/80 font-medium shrink-0 max-w-[30%] truncate"
|
||||
title={item.subject}>
|
||||
{item.subject}
|
||||
{item.subjectType && <EntityTypeBadge type={item.subjectType} />}
|
||||
</span>
|
||||
<span className="text-stone-500 shrink-0 italic">{item.predicate}</span>
|
||||
<span className="text-white/60 truncate" title={item.object}>
|
||||
{item.object}
|
||||
{item.objectType && <EntityTypeBadge type={item.objectType} />}
|
||||
</span>
|
||||
{item.evidenceCount > 1 && (
|
||||
<span className="ml-auto text-[9px] text-stone-600 shrink-0 tabular-nums">
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Renders memory query/recall text with highlighted entity type annotations,
|
||||
* plus an optional structured entity list when the backend returns entities
|
||||
* in the `context.entities[]` field.
|
||||
*
|
||||
* The backend surfaces entity types in text like:
|
||||
* "Alice (PERSON) -[OWNS]-> Atlas (PROJECT)"
|
||||
*
|
||||
* This component parses those `(TYPE)` annotations and renders them as
|
||||
* small styled badges inline, keeping the rest as plain text. When a
|
||||
* structured `entities` array is provided, it also renders a compact
|
||||
* entity chip bar above the text.
|
||||
*/
|
||||
import type { MemoryRetrievalEntity } from '../../utils/tauriCommands';
|
||||
|
||||
interface MemoryTextWithEntitiesProps {
|
||||
text: string;
|
||||
/** Structured entities from `context.entities[]` — shown as chips when present. */
|
||||
entities?: MemoryRetrievalEntity[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Matches parenthesized entity type annotations like (PERSON), (PROJECT), (ORG). */
|
||||
const ENTITY_TYPE_RE = /\(([A-Z][A-Z0-9_]{1,30})\)/g;
|
||||
|
||||
/** Deterministic colour palette for entity type badges (hue-shifted). */
|
||||
const TYPE_COLORS: Record<string, { bg: string; text: string; border: string }> = {
|
||||
PERSON: { bg: 'bg-sky-500/15', text: 'text-sky-300', border: 'border-sky-500/20' },
|
||||
PROJECT: { bg: 'bg-emerald-500/15', text: 'text-emerald-300', border: 'border-emerald-500/20' },
|
||||
ORG: { bg: 'bg-amber-500/15', text: 'text-amber-300', border: 'border-amber-500/20' },
|
||||
ORGANIZATION: { bg: 'bg-amber-500/15', text: 'text-amber-300', border: 'border-amber-500/20' },
|
||||
TECHNOLOGY: { bg: 'bg-violet-500/15', text: 'text-violet-300', border: 'border-violet-500/20' },
|
||||
TOOL: { bg: 'bg-violet-500/15', text: 'text-violet-300', border: 'border-violet-500/20' },
|
||||
LOCATION: { bg: 'bg-rose-500/15', text: 'text-rose-300', border: 'border-rose-500/20' },
|
||||
EVENT: { bg: 'bg-pink-500/15', text: 'text-pink-300', border: 'border-pink-500/20' },
|
||||
CONCEPT: { bg: 'bg-teal-500/15', text: 'text-teal-300', border: 'border-teal-500/20' },
|
||||
};
|
||||
|
||||
const DEFAULT_TYPE_COLOR = {
|
||||
bg: 'bg-primary-500/15',
|
||||
text: 'text-primary-300',
|
||||
border: 'border-primary-500/20',
|
||||
};
|
||||
|
||||
function colorForType(entityType: string): { bg: string; text: string; border: string } {
|
||||
return TYPE_COLORS[entityType.toUpperCase()] ?? DEFAULT_TYPE_COLOR;
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
kind: 'text' | 'entity-type';
|
||||
value: string;
|
||||
}
|
||||
|
||||
function parseEntityAnnotations(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(ENTITY_TYPE_RE)) {
|
||||
const matchStart = match.index;
|
||||
if (matchStart > lastIndex) {
|
||||
segments.push({ kind: 'text', value: text.slice(lastIndex, matchStart) });
|
||||
}
|
||||
segments.push({ kind: 'entity-type', value: match[1] });
|
||||
lastIndex = matchStart + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ kind: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Compact chip for a structured entity, showing name + optional type badge. */
|
||||
function EntityChip({ entity }: { entity: MemoryRetrievalEntity }) {
|
||||
const color = entity.entity_type ? colorForType(entity.entity_type) : DEFAULT_TYPE_COLOR;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded ${color.bg} border ${color.border}`}
|
||||
title={entity.entity_type ? `${entity.name} (${entity.entity_type})` : entity.name}>
|
||||
<span className={`text-[10px] leading-tight font-medium ${color.text}`}>{entity.name}</span>
|
||||
{entity.entity_type && (
|
||||
<span className="text-[8px] leading-tight font-semibold uppercase tracking-wide opacity-70">
|
||||
{entity.entity_type}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryTextWithEntities({ text, entities, className }: MemoryTextWithEntitiesProps) {
|
||||
if (!text && (!entities || entities.length === 0)) return null;
|
||||
|
||||
const hasStructuredEntities = entities && entities.length > 0;
|
||||
const hasInlineAnnotations = /\([A-Z][A-Z0-9_]{1,30}\)/.test(text);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Structured entity chips */}
|
||||
{hasStructuredEntities && (
|
||||
<div className="flex flex-wrap gap-1 mb-2 pb-2 border-b border-white/5">
|
||||
{entities.map((entity, i) => (
|
||||
<EntityChip key={entity.id ?? `${entity.name}-${i}`} entity={entity} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text content with inline entity type annotations */}
|
||||
{text && (
|
||||
<pre className="whitespace-pre-wrap m-0 p-0 font-inherit text-inherit leading-inherit">
|
||||
{hasInlineAnnotations
|
||||
? parseEntityAnnotations(text).map((seg, i) =>
|
||||
seg.kind === 'entity-type' ? (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-block mx-0.5 px-1 py-px rounded text-[9px] leading-tight font-semibold ${colorForType(seg.value).bg} ${colorForType(seg.value).text} border ${colorForType(seg.value).border} uppercase tracking-wide align-baseline`}
|
||||
title={`Entity type: ${seg.value}`}>
|
||||
{seg.value}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{seg.value}</span>
|
||||
)
|
||||
)
|
||||
: text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
memoryListDocuments,
|
||||
memoryListNamespaces,
|
||||
memoryQueryNamespace,
|
||||
type MemoryQueryResult,
|
||||
memoryRecallNamespace,
|
||||
} from '../../utils/tauriCommands';
|
||||
import { MemoryGraphMap } from './MemoryGraphMap';
|
||||
import { MemoryHeatmap } from './MemoryHeatmap';
|
||||
import { MemoryInsights } from './MemoryInsights';
|
||||
import { MemoryStatsBar } from './MemoryStatsBar';
|
||||
import { MemoryTextWithEntities } from './MemoryTextWithEntities';
|
||||
|
||||
type MemoryDoc = { documentId: string; namespace: string; title?: string; raw: unknown };
|
||||
|
||||
@@ -139,9 +141,9 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
const [selectedFileError, setSelectedFileError] = useState<string | null>(null);
|
||||
|
||||
const [queryInput, setQueryInput] = useState('important user preferences and active goals');
|
||||
const [queryResult, setQueryResult] = useState('');
|
||||
const [queryResult, setQueryResult] = useState<MemoryQueryResult | null>(null);
|
||||
const [queryLoading, setQueryLoading] = useState(false);
|
||||
const [recallResult, setRecallResult] = useState('');
|
||||
const [recallResult, setRecallResult] = useState<MemoryQueryResult | null>(null);
|
||||
const [recallLoading, setRecallLoading] = useState(false);
|
||||
const [memoryActionError, setMemoryActionError] = useState<string | null>(null);
|
||||
|
||||
@@ -254,7 +256,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
setQueryResult(response);
|
||||
} catch (error) {
|
||||
setMemoryActionError(error instanceof Error ? error.message : 'Query failed');
|
||||
setQueryResult('');
|
||||
setQueryResult(null);
|
||||
} finally {
|
||||
setQueryLoading(false);
|
||||
}
|
||||
@@ -266,10 +268,10 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
setMemoryActionError(null);
|
||||
try {
|
||||
const response = await memoryRecallNamespace(selectedNamespace, 10);
|
||||
setRecallResult(response ?? '');
|
||||
setRecallResult(response);
|
||||
} catch (error) {
|
||||
setMemoryActionError(error instanceof Error ? error.message : 'Recall failed');
|
||||
setRecallResult('');
|
||||
setRecallResult(null);
|
||||
} finally {
|
||||
setRecallLoading(false);
|
||||
}
|
||||
@@ -515,15 +517,19 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-[11px] text-stone-500 mb-1">Query response</div>
|
||||
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
|
||||
{queryResult || 'No query result yet.'}
|
||||
</pre>
|
||||
<MemoryTextWithEntities
|
||||
text={queryResult?.text || 'No query result yet.'}
|
||||
entities={queryResult?.entities}
|
||||
className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-stone-500 mb-1">Recall response</div>
|
||||
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
|
||||
{recallResult || 'No recall result yet.'}
|
||||
</pre>
|
||||
<MemoryTextWithEntities
|
||||
text={recallResult?.text || 'No recall result yet.'}
|
||||
entities={recallResult?.entities}
|
||||
className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ vi.mock('../../../utils/tauriCommands', () => ({
|
||||
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'),
|
||||
memoryQueryNamespace: vi.fn().mockResolvedValue({ text: 'query result', entities: [] }),
|
||||
memoryRecallNamespace: vi.fn().mockResolvedValue({ text: 'recall result', entities: [] }),
|
||||
memoryGraphQuery: vi.fn().mockResolvedValue([
|
||||
{
|
||||
namespace: 'research',
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
memoryListDocuments,
|
||||
memoryListNamespaces,
|
||||
memoryQueryNamespace,
|
||||
type MemoryQueryResult,
|
||||
memoryRecallNamespace,
|
||||
} from '../../../utils/tauriCommands';
|
||||
import { MemoryTextWithEntities } from '../../intelligence/MemoryTextWithEntities';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import { PrimaryButton } from './components/ActionPanel';
|
||||
@@ -30,8 +32,8 @@ const MemoryDebugPanel = () => {
|
||||
const [namespaceInput, setNamespaceInput] = useState('');
|
||||
const [queryInput, setQueryInput] = useState('');
|
||||
const [maxChunksInput, setMaxChunksInput] = useState('10');
|
||||
const [queryResult, setQueryResult] = useState<string | null>(null);
|
||||
const [recallResult, setRecallResult] = useState<string | null>(null);
|
||||
const [queryResult, setQueryResult] = useState<MemoryQueryResult | null>(null);
|
||||
const [recallResult, setRecallResult] = useState<MemoryQueryResult | null>(null);
|
||||
const [queryError, setQueryError] = useState<string | null>(null);
|
||||
const [recallError, setRecallError] = useState<string | null>(null);
|
||||
const [queryLoading, setQueryLoading] = useState(false);
|
||||
@@ -134,7 +136,7 @@ const MemoryDebugPanel = () => {
|
||||
setRecallResult(null);
|
||||
try {
|
||||
const result = await memoryRecallNamespace(namespaceInput.trim(), maxChunks);
|
||||
setRecallResult(result ?? '');
|
||||
setRecallResult(result);
|
||||
} catch (error) {
|
||||
setRecallError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
@@ -411,13 +413,17 @@ const MemoryDebugPanel = () => {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-stone-400">Query response</div>
|
||||
<pre className="rounded border border-stone-700 bg-black/20 p-2 overflow-auto text-[11px] leading-5 min-h-16">
|
||||
{queryResult ?? ''}
|
||||
</pre>
|
||||
<MemoryTextWithEntities
|
||||
text={queryResult?.text ?? ''}
|
||||
entities={queryResult?.entities}
|
||||
className="rounded border border-stone-700 bg-black/20 p-2 overflow-auto text-[11px] leading-5 min-h-16 whitespace-pre-wrap"
|
||||
/>
|
||||
<div className="text-xs text-stone-400">Recall response</div>
|
||||
<pre className="rounded border border-stone-700 bg-black/20 p-2 overflow-auto text-[11px] leading-5 min-h-16">
|
||||
{recallResult ?? ''}
|
||||
</pre>
|
||||
<MemoryTextWithEntities
|
||||
text={recallResult?.text ?? ''}
|
||||
entities={recallResult?.entities}
|
||||
className="rounded border border-stone-700 bg-black/20 p-2 overflow-auto text-[11px] leading-5 min-h-16 whitespace-pre-wrap"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -159,12 +159,12 @@ export function useConsciousItems(): UseConsciousItemsResult {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const context = await memoryQueryNamespace(
|
||||
const queryResult = await memoryQueryNamespace(
|
||||
'conscious',
|
||||
'actionable items priority source title description',
|
||||
20
|
||||
);
|
||||
const extracted = extractActionablesFromContext(context);
|
||||
const extracted = extractActionablesFromContext(queryResult.text);
|
||||
setItems(extracted.map(mapToActionableItem));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load conscious items';
|
||||
|
||||
@@ -295,31 +295,85 @@ export async function memoryClearNamespace(
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/** A single entity returned in the structured retrieval context. */
|
||||
export interface MemoryRetrievalEntity {
|
||||
id?: string;
|
||||
name: string;
|
||||
entity_type?: string;
|
||||
score?: number;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
/** Structured retrieval context returned alongside `llm_context_message`. */
|
||||
export interface MemoryRetrievalContext {
|
||||
entities: MemoryRetrievalEntity[];
|
||||
relations: { subject: string; predicate: string; object: string; score?: number }[];
|
||||
chunks: { content: string; score: number; chunk_id?: string; document_id?: string }[];
|
||||
}
|
||||
|
||||
/** Result of a memory query or recall, combining text and structured data. */
|
||||
export interface MemoryQueryResult {
|
||||
text: string;
|
||||
entities: MemoryRetrievalEntity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw envelope shape returned by `openhuman.memory_query_namespace` and
|
||||
* `openhuman.memory_recall_context` via the registry-based RPC handler.
|
||||
*/
|
||||
interface MemoryQueryEnvelope {
|
||||
data?: { llm_context_message?: string | null; context?: MemoryRetrievalContext | null };
|
||||
llm_context_message?: string | null;
|
||||
context?: MemoryRetrievalContext | null;
|
||||
}
|
||||
|
||||
/** Extract text + entities from the envelope returned by query/recall RPCs. */
|
||||
function unwrapMemoryQueryResult(resp: unknown): MemoryQueryResult {
|
||||
// If the response is already a plain string, return it directly.
|
||||
if (typeof resp === 'string') {
|
||||
return { text: resp, entities: [] };
|
||||
}
|
||||
|
||||
const envelope = resp as MemoryQueryEnvelope | null;
|
||||
if (!envelope || typeof envelope !== 'object') {
|
||||
return { text: '', entities: [] };
|
||||
}
|
||||
|
||||
// Envelope may be `{ data: { llm_context_message, context } }` or flat.
|
||||
const inner = envelope.data ?? envelope;
|
||||
const text = inner.llm_context_message ?? '';
|
||||
const entities = inner.context?.entities ?? [];
|
||||
|
||||
return { text, entities };
|
||||
}
|
||||
|
||||
export async function memoryQueryNamespace(
|
||||
namespace: string,
|
||||
query: string,
|
||||
maxChunks?: number
|
||||
): Promise<string> {
|
||||
): Promise<MemoryQueryResult> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<string>({
|
||||
const resp = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.memory_query_namespace',
|
||||
params: { namespace, query, max_chunks: maxChunks },
|
||||
});
|
||||
return unwrapMemoryQueryResult(resp);
|
||||
}
|
||||
|
||||
export async function memoryRecallNamespace(
|
||||
namespace: string,
|
||||
maxChunks?: number
|
||||
): Promise<string | null> {
|
||||
): Promise<MemoryQueryResult> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<string | null>({
|
||||
const resp = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.memory_recall_context',
|
||||
params: { namespace, max_chunks: maxChunks },
|
||||
});
|
||||
return unwrapMemoryQueryResult(resp);
|
||||
}
|
||||
|
||||
export interface GraphRelation {
|
||||
|
||||
@@ -250,11 +250,19 @@ fn format_llm_context_message(query: Option<&str>, hits: &[NamespaceMemoryHit])
|
||||
.supporting_relations
|
||||
.iter()
|
||||
.map(|relation| {
|
||||
let subject_type = extract_entity_type(&relation.attrs, "subject");
|
||||
let object_type = extract_entity_type(&relation.attrs, "object");
|
||||
let subject_label = match subject_type {
|
||||
Some(t) => format!("{} ({})", relation.subject, t),
|
||||
None => relation.subject.clone(),
|
||||
};
|
||||
let object_label = match object_type {
|
||||
Some(t) => format!("{} ({})", relation.object, t),
|
||||
None => relation.object.clone(),
|
||||
};
|
||||
format!(
|
||||
"{} -[{}]-> {}",
|
||||
relation.subject.as_str(),
|
||||
relation.predicate.as_str(),
|
||||
relation.object.as_str()
|
||||
subject_label, relation.predicate, object_label
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -1162,6 +1170,18 @@ mod tests {
|
||||
let message = format_llm_context_message(Some("who owns atlas"), &[hit])
|
||||
.expect("context message should exist");
|
||||
assert!(message.contains("Query: who owns atlas"));
|
||||
// Without entity_types in attrs, relations render without type annotations.
|
||||
assert!(message.contains("Alice -[OWNS]-> Atlas"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_llm_context_message_includes_entity_types_when_present() {
|
||||
let hit = sample_hit_with_entity_types();
|
||||
let message = format_llm_context_message(Some("who owns atlas"), &[hit])
|
||||
.expect("context message should exist");
|
||||
assert!(
|
||||
message.contains("Alice (PERSON) -[OWNS]-> Atlas (PROJECT)"),
|
||||
"expected entity types in relation text, got: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1203,6 +1203,18 @@ impl UnifiedMemory {
|
||||
}
|
||||
}
|
||||
|
||||
fn entity_label_with_type(name: &str, attrs: &serde_json::Value, role: &str) -> String {
|
||||
let entity_type = attrs
|
||||
.get("entity_types")
|
||||
.and_then(|et| et.get(role))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty());
|
||||
match entity_type {
|
||||
Some(t) => format!("{name} ({t})"),
|
||||
None => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_context_text(hits: &[NamespaceMemoryHit], query: Option<&str>) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(query) = query {
|
||||
@@ -1229,9 +1241,19 @@ impl UnifiedMemory {
|
||||
.supporting_relations
|
||||
.iter()
|
||||
.map(|relation| {
|
||||
let subject_label = Self::entity_label_with_type(
|
||||
&relation.subject,
|
||||
&relation.attrs,
|
||||
"subject",
|
||||
);
|
||||
let object_label = Self::entity_label_with_type(
|
||||
&relation.object,
|
||||
&relation.attrs,
|
||||
"object",
|
||||
);
|
||||
format!(
|
||||
"{} -[{}]-> {}",
|
||||
relation.subject, relation.predicate, relation.object
|
||||
subject_label, relation.predicate, object_label
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -1466,4 +1488,166 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_supporting_relations_contain_entity_types() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
|
||||
let document_id = memory
|
||||
.upsert_document(NamespaceDocumentInput {
|
||||
namespace: "team".to_string(),
|
||||
key: "alice-google".to_string(),
|
||||
title: "Alice at Google".to_string(),
|
||||
content: "Alice works on Project Alpha at Google.".to_string(),
|
||||
source_type: "doc".to_string(),
|
||||
priority: "high".to_string(),
|
||||
tags: vec!["decision".to_string()],
|
||||
metadata: json!({}),
|
||||
category: "core".to_string(),
|
||||
session_id: None,
|
||||
document_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Upsert graph relations with entity types in attrs (mimics ingestion pipeline).
|
||||
memory
|
||||
.graph_upsert_namespace(
|
||||
"team",
|
||||
"Alice",
|
||||
"WORKS_FOR",
|
||||
"Google",
|
||||
&json!({
|
||||
"document_id": document_id,
|
||||
"entity_types": {
|
||||
"subject": "PERSON",
|
||||
"object": "ORGANIZATION"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
memory
|
||||
.graph_upsert_namespace(
|
||||
"team",
|
||||
"Alice",
|
||||
"OWNS",
|
||||
"Project Alpha",
|
||||
&json!({
|
||||
"document_id": document_id,
|
||||
"entity_types": {
|
||||
"subject": "PERSON",
|
||||
"object": "PROJECT"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Query path: entity types should appear in supporting_relations attrs.
|
||||
let hits = memory
|
||||
.query_namespace_hits("team", "Alice", 5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!hits.is_empty(), "should return at least one hit");
|
||||
|
||||
let hit = &hits[0];
|
||||
assert!(
|
||||
!hit.supporting_relations.is_empty(),
|
||||
"hit should have supporting relations"
|
||||
);
|
||||
|
||||
// Verify entity types are present in the attrs of supporting relations.
|
||||
for relation in &hit.supporting_relations {
|
||||
let entity_types = relation.attrs.get("entity_types");
|
||||
assert!(
|
||||
entity_types.is_some(),
|
||||
"relation {} -[{}]-> {} should have entity_types in attrs",
|
||||
relation.subject,
|
||||
relation.predicate,
|
||||
relation.object
|
||||
);
|
||||
let et = entity_types.unwrap();
|
||||
let subject_type = et.get("subject").and_then(|v| v.as_str());
|
||||
assert_eq!(
|
||||
subject_type,
|
||||
Some("PERSON"),
|
||||
"subject_type should be PERSON for Alice"
|
||||
);
|
||||
}
|
||||
|
||||
// Recall path: entity types should also appear.
|
||||
let recall_hits = memory.recall_namespace_memories("team", 5).await.unwrap();
|
||||
assert!(!recall_hits.is_empty(), "recall should return hits");
|
||||
|
||||
let recall_hit = &recall_hits[0];
|
||||
assert!(
|
||||
!recall_hit.supporting_relations.is_empty(),
|
||||
"recall hit should have supporting relations"
|
||||
);
|
||||
for relation in &recall_hit.supporting_relations {
|
||||
let entity_types = relation.attrs.get("entity_types");
|
||||
assert!(
|
||||
entity_types.is_some(),
|
||||
"recall relation should have entity_types in attrs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn format_context_text_includes_entity_types() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
|
||||
|
||||
let document_id = memory
|
||||
.upsert_document(NamespaceDocumentInput {
|
||||
namespace: "team".to_string(),
|
||||
key: "atlas-status".to_string(),
|
||||
title: "Atlas status".to_string(),
|
||||
content: "Project Atlas is owned by Alice at Google.".to_string(),
|
||||
source_type: "doc".to_string(),
|
||||
priority: "high".to_string(),
|
||||
tags: vec!["decision".to_string()],
|
||||
metadata: json!({}),
|
||||
category: "core".to_string(),
|
||||
session_id: None,
|
||||
document_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
memory
|
||||
.graph_upsert_namespace(
|
||||
"team",
|
||||
"Alice",
|
||||
"OWNS",
|
||||
"Atlas",
|
||||
&json!({
|
||||
"document_id": document_id,
|
||||
"entity_types": {
|
||||
"subject": "PERSON",
|
||||
"object": "PROJECT"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let context = memory
|
||||
.query_namespace_context_data("team", "who owns atlas", 5)
|
||||
.await
|
||||
.unwrap();
|
||||
// Entity names are normalized to uppercase during graph upsert.
|
||||
assert!(
|
||||
context.context_text.contains("ALICE (PERSON)"),
|
||||
"context_text should include entity type for Alice, got: {}",
|
||||
context.context_text
|
||||
);
|
||||
assert!(
|
||||
context.context_text.contains("ATLAS (PROJECT)"),
|
||||
"context_text should include entity type for Atlas, got: {}",
|
||||
context.context_text
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user