diff --git a/app/src/components/intelligence/MemoryGraphMap.tsx b/app/src/components/intelligence/MemoryGraphMap.tsx index 6cef5b5dd..622133f81 100644 --- a/app/src/components/intelligence/MemoryGraphMap.tsx +++ b/app/src/components/intelligence/MemoryGraphMap.tsx @@ -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(); + 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; 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)} + {node.entityType && ( + + {node.entityType} + + )} ); })} diff --git a/app/src/components/intelligence/MemoryInsights.tsx b/app/src/components/intelligence/MemoryInsights.tsx index 68eebb780..0d0f32fa7 100644 --- a/app/src/components/intelligence/MemoryInsights.tsx +++ b/app/src/components/intelligence/MemoryInsights.tsx @@ -30,6 +30,8 @@ interface InsightItem { evidenceCount: number; namespace: string | null; updatedAt: number; + subjectType: string | null; + objectType: string | null; } const PREDICATE_CATEGORIES: Record = { @@ -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 ( + + {type} + + ); +} + export function MemoryInsights({ relations, loading }: MemoryInsightsProps) { const [expandedCategory, setExpandedCategory] = useState(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; 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 && } {item.predicate} {item.object} + {item.objectType && } {item.evidenceCount > 1 && ( diff --git a/app/src/components/intelligence/MemoryTextWithEntities.tsx b/app/src/components/intelligence/MemoryTextWithEntities.tsx new file mode 100644 index 000000000..f97f99436 --- /dev/null +++ b/app/src/components/intelligence/MemoryTextWithEntities.tsx @@ -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 = { + 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 ( + + {entity.name} + {entity.entity_type && ( + + {entity.entity_type} + + )} + + ); +} + +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 ( +
+ {/* Structured entity chips */} + {hasStructuredEntities && ( +
+ {entities.map((entity, i) => ( + + ))} +
+ )} + + {/* Text content with inline entity type annotations */} + {text && ( +
+          {hasInlineAnnotations
+            ? parseEntityAnnotations(text).map((seg, i) =>
+                seg.kind === 'entity-type' ? (
+                  
+                    {seg.value}
+                  
+                ) : (
+                  {seg.value}
+                )
+              )
+            : text}
+        
+ )} +
+ ); +} diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index 57a527fdb..ac1427761 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -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(null); const [queryInput, setQueryInput] = useState('important user preferences and active goals'); - const [queryResult, setQueryResult] = useState(''); + const [queryResult, setQueryResult] = useState(null); const [queryLoading, setQueryLoading] = useState(false); - const [recallResult, setRecallResult] = useState(''); + const [recallResult, setRecallResult] = useState(null); const [recallLoading, setRecallLoading] = useState(false); const [memoryActionError, setMemoryActionError] = useState(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) {
Query response
-
-                      {queryResult || 'No query result yet.'}
-                    
+
Recall response
-
-                      {recallResult || 'No recall result yet.'}
-                    
+
diff --git a/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx index e77e58e2d..c94915f9f 100644 --- a/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx +++ b/app/src/components/intelligence/__tests__/MemoryWorkspace.test.tsx @@ -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', diff --git a/app/src/components/settings/panels/MemoryDebugPanel.tsx b/app/src/components/settings/panels/MemoryDebugPanel.tsx index c720e5834..88e9fa164 100644 --- a/app/src/components/settings/panels/MemoryDebugPanel.tsx +++ b/app/src/components/settings/panels/MemoryDebugPanel.tsx @@ -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(null); - const [recallResult, setRecallResult] = useState(null); + const [queryResult, setQueryResult] = useState(null); + const [recallResult, setRecallResult] = useState(null); const [queryError, setQueryError] = useState(null); const [recallError, setRecallError] = useState(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 = () => {
Query response
-
-                {queryResult ?? ''}
-              
+
Recall response
-
-                {recallResult ?? ''}
-              
+
diff --git a/app/src/hooks/useConsciousItems.ts b/app/src/hooks/useConsciousItems.ts index 9a9db97d9..7d5eadb05 100644 --- a/app/src/hooks/useConsciousItems.ts +++ b/app/src/hooks/useConsciousItems.ts @@ -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'; diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts index c13a40990..d9358738d 100644 --- a/app/src/utils/tauriCommands.ts +++ b/app/src/utils/tauriCommands.ts @@ -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 { +): Promise { if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await callCoreRpc({ + const resp = await callCoreRpc({ method: 'openhuman.memory_query_namespace', params: { namespace, query, max_chunks: maxChunks }, }); + return unwrapMemoryQueryResult(resp); } export async function memoryRecallNamespace( namespace: string, maxChunks?: number -): Promise { +): Promise { if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await callCoreRpc({ + const resp = await callCoreRpc({ method: 'openhuman.memory_recall_context', params: { namespace, max_chunks: maxChunks }, }); + return unwrapMemoryQueryResult(resp); } export interface GraphRelation { diff --git a/src/openhuman/memory/ops.rs b/src/openhuman/memory/ops.rs index 9768a0949..f72abb8d0 100644 --- a/src/openhuman/memory/ops.rs +++ b/src/openhuman/memory/ops.rs @@ -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::>() @@ -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}" + ); + } } diff --git a/src/openhuman/memory/store/unified/query.rs b/src/openhuman/memory/store/unified/query.rs index a19adba96..1dfaac4d2 100644 --- a/src/openhuman/memory/store/unified/query.rs +++ b/src/openhuman/memory/store/unified/query.rs @@ -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::>() @@ -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 + ); + } }