feat(memory): surface entity types in recall/query API and UI (#207) (#302)

* 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:
sanil-23
2026-04-03 20:34:55 +05:30
committed by GitHub
co-authored by sanil jain Claude Opus 4.6
parent 15ee9220a8
commit 735152cb3f
10 changed files with 477 additions and 34 deletions
@@ -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>
+2 -2
View File
@@ -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';
+58 -4
View File
@@ -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 {
+23 -3
View File
@@ -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}"
);
}
}
+185 -1
View File
@@ -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
);
}
}