Skip to content

Commit 42ef1df

Browse files
Jurij89Jurij Skornik
andauthored
fix(node-ui): clarify context graph entity labels (#605)
Co-authored-by: Jurij Skornik <[email protected]>
1 parent e0f6b54 commit 42ef1df

5 files changed

Lines changed: 253 additions & 29 deletions

File tree

packages/node-ui/src/ui/hooks/useMemoryEntities.ts

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export interface MemoryData {
4848
}
4949

5050
const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
51+
const NAME_PREDS = [
52+
'http://schema.org/name',
53+
'http://www.w3.org/2000/01/rdf-schema#label',
54+
'http://purl.org/dc/terms/title',
55+
'http://purl.org/dc/elements/1.1/title',
56+
'http://xmlns.com/foaf/0.1/name',
57+
];
5158

5259
function bv(v: unknown): string | undefined {
5360
if (v == null) return undefined;
@@ -75,6 +82,56 @@ function shortPredicate(uri: string): string {
7582
return s.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
7683
}
7784

85+
function uriTail(uri: string): string {
86+
const hash = uri.lastIndexOf('#');
87+
const slash = uri.lastIndexOf('/');
88+
const colon = uri.lastIndexOf(':');
89+
const cut = Math.max(hash, slash, colon);
90+
const raw = cut >= 0 ? uri.slice(cut + 1) : uri;
91+
try {
92+
return decodeURIComponent(raw);
93+
} catch {
94+
return raw;
95+
}
96+
}
97+
98+
function readableTail(uri: string): string {
99+
const tail = uriTail(uri).trim();
100+
if (!tail) return '';
101+
const shortened = /^[0-9a-f-]{16,}$/i.test(tail) ? tail.replace(/-/g, '').slice(0, 12) : tail;
102+
return shortened.replace(/[_-]+/g, ' ').trim();
103+
}
104+
105+
function isRawExtractionLabel(label: string, uri: string): boolean {
106+
return label === uri && /^urn:dkg:extraction:[^\s]+$/i.test(uri);
107+
}
108+
109+
function readableFallbackLabel(entity: MemoryEntity): string {
110+
const tail = readableTail(entity.uri);
111+
const type = entity.types
112+
.map(shortLabel)
113+
.find(t => t && t !== 'Thing' && t !== 'Entity');
114+
if (/^urn:dkg:extraction:[^\s]+$/i.test(entity.uri)) {
115+
return tail ? `Extraction ${tail}` : 'Extraction';
116+
}
117+
if (type) return tail && tail !== type ? `${type} ${tail}` : type;
118+
return tail || shortLabel(entity.uri);
119+
}
120+
121+
function deriveEntityLabel(entity: MemoryEntity): string {
122+
for (const pred of NAME_PREDS) {
123+
const vals = entity.properties.get(pred);
124+
const name = vals?.find(v => v.trim().length > 0);
125+
if (name) return name;
126+
}
127+
128+
if (entity.label && !isRawExtractionLabel(entity.label, entity.uri)) {
129+
return entity.label;
130+
}
131+
132+
return readableFallbackLabel(entity);
133+
}
134+
78135
// All three layer queries walk the named-graph space directly with a
79136
// FILTER on the graph URI, rather than going through the daemon's
80137
// built-in `view` helpers. Two wins from this:
@@ -279,27 +336,20 @@ function buildEntities(layered: LayeredTriple[]): Map<string, MemoryEntity> {
279336
}
280337
}
281338

282-
const NAME_PREDS = [
283-
'http://schema.org/name',
284-
'http://www.w3.org/2000/01/rdf-schema#label',
285-
'http://purl.org/dc/terms/title',
286-
'http://xmlns.com/foaf/0.1/name',
287-
];
288-
289339
for (const entity of entities.values()) {
290-
for (const pred of NAME_PREDS) {
291-
const vals = entity.properties.get(pred);
292-
if (vals?.[0]) {
293-
entity.label = vals[0];
294-
break;
295-
}
296-
}
340+
entity.label = deriveEntityLabel(entity);
297341

298342
if (entity.layers.has('verified')) entity.trustLevel = 'verified';
299343
else if (entity.layers.has('shared')) entity.trustLevel = 'shared';
300344
else entity.trustLevel = 'working';
301345
}
302346

347+
for (const entity of entities.values()) {
348+
for (const connection of entity.connections) {
349+
connection.targetLabel = entities.get(connection.targetUri)?.label ?? shortLabel(connection.targetUri);
350+
}
351+
}
352+
303353
return entities;
304354
}
305355

packages/node-ui/src/ui/views/project/components.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import {
5151
entityAuthorUri, transitionAgentUri, transitionAtISO,
5252
shortType, shortPred, entityMeta,
5353
buildLayerGraphOptions, getDescription, neighborhoodTriples,
54-
matchesSearch, humanizeLabel, useLayerTriples,
54+
matchesSearch, humanizeLabel, layerNoun, useLayerTriples,
5555
entityTimestamp, formatRelativeTime, formatTimelineBucket, formatTrailTimestamp,
5656
type LayerView, type LayerContentTab, type KAPane,
5757
type SubGraphTab, type SubGraphEntitySort,
@@ -602,7 +602,7 @@ export function MemoryStrip({
602602
<div className="v10-layer-items">
603603
<span className="v10-layer-chevron"></span>
604604
{layer.entities.length === 0 && (
605-
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', fontStyle: 'italic' }}>No assets yet</span>
605+
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', fontStyle: 'italic' }}>No {layerNoun(layer.key, 2).toLowerCase()} yet</span>
606606
)}
607607
{layer.entities.slice(0, 6).map(e => {
608608
const { icon } = entityMeta(e, profile);
@@ -782,7 +782,7 @@ export function LayerStatsWidget({ entities, entityCount, triples, layer }: {
782782
<GenWidget title="Layer Stats">
783783
<div className="v10-layer-summary">
784784
<div className="v10-layer-summary-stat">
785-
<span className="v10-layer-summary-label">Knowledge Assets</span>
785+
<span className="v10-layer-summary-label">{layerNoun(layer, entityCount)}</span>
786786
<span className="v10-layer-summary-value">{entityCount}</span>
787787
</div>
788788
<div className="v10-layer-summary-stat">
@@ -849,12 +849,13 @@ export function LayerActionsWidget({ layer, count, contextGraphId, onComplete }:
849849

850850
if (count === 0) return null;
851851
const color = isWm ? '#f59e0b' : '#22c55e';
852-
const target = isWm ? 'Shared Memory' : 'Verified Memory';
852+
const target = isWm ? 'Shared Working Memory' : 'Verified Memory';
853+
const noun = layerNoun(layer, count).toLowerCase();
853854

854855
return (
855-
<GenWidget title={isWm ? 'Promote' : 'Publish'} footnote={`Moves assets from this layer to ${target}.`}>
856+
<GenWidget title={isWm ? 'Promote' : 'Publish'} footnote={`Moves ${noun} from this layer to ${target}.`}>
856857
<div className="v10-decision-context" style={{ marginBottom: 10 }}>
857-
{count} asset{count !== 1 ? 's' : ''} in this layer can be {isWm ? 'promoted to Shared Memory for collaborative review' : 'published to Verified Memory on-chain'}.
858+
{count} {noun} in this layer can be {isWm ? 'promoted to Shared Working Memory for collaborative review' : 'published to Verified Memory on-chain'}.
858859
</div>
859860
{result && <div style={{ fontSize: 11, color: 'var(--text-success)', marginBottom: 8 }}>{result}</div>}
860861
{error && <div style={{ fontSize: 11, color: 'var(--text-danger)', marginBottom: 8 }}>{error}</div>}
@@ -944,6 +945,7 @@ export function EntityList({
944945
}) {
945946
const profile = useProjectProfileContext();
946947
const agents = useAgentsContext();
948+
const noun = layerNoun(layerKey, entities.length).toLowerCase();
947949
const sorted = useMemo(() => {
948950
if (externallySorted) return entities;
949951
const copy = [...entities];
@@ -960,7 +962,7 @@ export function EntityList({
960962
if (entities.length === 0) {
961963
return (
962964
<div className="v10-entity-list empty">
963-
<div className="v10-entity-list-empty">No entities in this layer yet.</div>
965+
<div className="v10-entity-list-empty">No {layerNoun(layerKey, 2).toLowerCase()} in this layer yet.</div>
964966
</div>
965967
);
966968
}
@@ -970,7 +972,7 @@ export function EntityList({
970972
return (
971973
<div className="v10-entity-list">
972974
<div className="v10-entity-list-header">
973-
<span className="v10-entity-list-count">{sorted.length} entit{sorted.length === 1 ? 'y' : 'ies'}</span>
975+
<span className="v10-entity-list-count">{sorted.length} {noun}</span>
974976
<span className="v10-entity-list-hint">{hint}</span>
975977
{headerExtra && <span className="v10-entity-list-extra">{headerExtra}</span>}
976978
</div>
@@ -1051,7 +1053,7 @@ export function LayerContent({
10511053
footer?: React.ReactNode;
10521054
}) {
10531055
const config = LAYER_CONFIG[layer];
1054-
const itemsLabel = layer === 'vm' ? 'Knowledge Assets' : 'Entities';
1056+
const itemsLabel = layerNoun(layer, 2);
10551057
const vmLayerStatus = memory.layerStatus?.vm ?? (memory.loading ? 'loading' : memory.error ? 'error' : 'ok');
10561058
const isInitialVerifiedMemoryLoad = layer === 'vm' && vmLayerStatus === 'loading' && entities.length === 0;
10571059
const isVerifiedMemoryUnavailable = layer === 'vm' && vmLayerStatus === 'error' && entities.length === 0;
@@ -1989,9 +1991,9 @@ export function DocumentsList({
19891991

19901992
export function ProvenanceBar({ memory }: { memory: ReturnType<typeof useMemoryEntities> }) {
19911993
const latestEvent = useMemo(() => {
1992-
if (memory.counts.vm > 0) return `${memory.counts.vm} knowledge assets verified on-chain`;
1993-
if (memory.counts.swm > 0) return `${memory.counts.swm} assets in shared memory`;
1994-
if (memory.counts.wm > 0) return `${memory.counts.wm} drafts in working memory`;
1994+
if (memory.counts.vm > 0) return `${memory.counts.vm} ${layerNoun('vm', memory.counts.vm).toLowerCase()} verified on-chain`;
1995+
if (memory.counts.swm > 0) return `${memory.counts.swm} ${layerNoun('swm', memory.counts.swm).toLowerCase()} in shared working memory`;
1996+
if (memory.counts.wm > 0) return `${memory.counts.wm} ${layerNoun('wm', memory.counts.wm).toLowerCase()} in working memory`;
19951997
return 'No activity yet';
19961998
}, [memory.counts]);
19971999

@@ -2233,6 +2235,7 @@ export function KADetailView({ entity, allEntities, allTriples, onNavigate, onCl
22332235
const author = authorUri ? agents?.get(authorUri) ?? null : null;
22342236
const layerBadge = entity.trustLevel === 'verified' ? 'vm' : entity.trustLevel === 'shared' ? 'swm' : 'wm';
22352237
const layerLabel = entity.trustLevel === 'verified' ? 'Verified Memory' : entity.trustLevel === 'shared' ? 'Shared Working Memory' : 'Working Memory';
2238+
const detailNoun = layerNoun(entity.trustLevel, 1);
22362239

22372240
const incoming = useMemo(() => {
22382241
const result: Array<{ pred: string; entity: MemoryEntity }> = [];
@@ -2294,7 +2297,7 @@ export function KADetailView({ entity, allEntities, allTriples, onNavigate, onCl
22942297
<div className="v10-ka-header">
22952298
<button className="v10-ka-back" onClick={onClose}>← Back to Context Graph</button>
22962299
<div className="v10-ka-header-left">
2297-
<div className="v10-ka-label">Knowledge Asset</div>
2300+
<div className="v10-ka-label">{detailNoun}</div>
22982301
<div className="v10-ka-name">
22992302
{icon} {entity.label}
23002303
<span className={`v10-trust-badge ${layerBadge}`}>{layerLabel}</span>

packages/node-ui/src/ui/views/project/helpers.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,20 @@ export const LAYER_CONFIG: Record<'wm' | 'swm' | 'vm', {
169169
},
170170
};
171171

172+
export function layerNoun(
173+
layer: 'wm' | 'swm' | 'vm' | TrustLevel,
174+
count: number = 2,
175+
): string {
176+
const normalized =
177+
layer === 'working' ? 'wm' :
178+
layer === 'shared' ? 'swm' :
179+
layer === 'verified' ? 'vm' :
180+
layer;
181+
const plural = count !== 1;
182+
if (normalized === 'vm') return plural ? 'Knowledge Assets' : 'Knowledge Asset';
183+
return plural ? 'Entities' : 'Entity';
184+
}
185+
172186
// ─── Shared graph styling ────────────────────────────────────
173187
// Rich palette for known ontologies — applied in any RdfGraph rendered
174188
// from this view. Nodes without matching types fall back to the layer's
@@ -304,8 +318,18 @@ export function humanizeLabel(entity: MemoryEntity | undefined, uri: string): st
304318
if (entity) return entity.label;
305319
const slash = uri.lastIndexOf('/');
306320
const hash = uri.lastIndexOf('#');
307-
const cut = Math.max(slash, hash);
308-
return cut >= 0 ? uri.slice(cut + 1) : uri;
321+
const colon = uri.lastIndexOf(':');
322+
const cut = Math.max(slash, hash, colon);
323+
const tail = cut >= 0 ? uri.slice(cut + 1) : uri;
324+
const decoded = (() => {
325+
try { return decodeURIComponent(tail); }
326+
catch { return tail; }
327+
})();
328+
if (/^urn:dkg:extraction:[^\s]+$/i.test(uri)) {
329+
const short = decoded.replace(/-/g, '').replace(/_+/g, ' ').slice(0, 12).trim();
330+
return short ? `Extraction ${short}` : 'Extraction';
331+
}
332+
return decoded || uri;
309333
}
310334

311335
// ─── Layer Switcher Bar ──────────────────────────────────────

packages/node-ui/test/ka-detail-label.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,24 @@ import { act } from 'react';
55
import { createRoot, type Root } from 'react-dom/client';
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
77
import { KADetailView, TrailEvent } from '../src/ui/views/project/components.js';
8+
import { layerNoun } from '../src/ui/views/project/helpers.js';
89
import { ProjectProfileContext, type ProjectProfile } from '../src/ui/hooks/useProjectProfile.js';
910
import { AgentsContext, type AgentsData } from '../src/ui/hooks/useAgents.js';
1011
import type { MemoryEntity } from '../src/ui/hooks/useMemoryEntities.js';
1112

1213
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
1314

15+
describe('layerNoun', () => {
16+
it('uses entities for WM/SWM and knowledge assets for VM', () => {
17+
expect(layerNoun('wm', 1)).toBe('Entity');
18+
expect(layerNoun('wm', 2)).toBe('Entities');
19+
expect(layerNoun('swm', 1)).toBe('Entity');
20+
expect(layerNoun('swm', 2)).toBe('Entities');
21+
expect(layerNoun('vm', 1)).toBe('Knowledge Asset');
22+
expect(layerNoun('vm', 2)).toBe('Knowledge Assets');
23+
});
24+
});
25+
1426
const profile: ProjectProfile = {
1527
contextGraphId: 'cg-test',
1628
displayName: 'Context Graph Test',
@@ -106,6 +118,35 @@ describe('KADetailView navigation label', () => {
106118
expect(onClose).toHaveBeenCalledTimes(1);
107119
});
108120

121+
it('uses layer-aware nouns in the detail header', async () => {
122+
const render = async (testEntity: MemoryEntity) => {
123+
await act(async () => {
124+
root.render(
125+
React.createElement(ProjectProfileContext.Provider, { value: profile },
126+
React.createElement(AgentsContext.Provider, { value: agents },
127+
React.createElement(KADetailView, {
128+
entity: testEntity,
129+
allEntities: new Map([[testEntity.uri, testEntity]]),
130+
allTriples: [],
131+
onNavigate: vi.fn(),
132+
onClose: vi.fn(),
133+
contextGraphId: 'cg-test',
134+
onRefresh: vi.fn(),
135+
}))),
136+
);
137+
});
138+
};
139+
140+
await render({ ...entity, trustLevel: 'working', layers: new Set(['working']) });
141+
expect(query('.v10-ka-label').textContent).toBe('Entity');
142+
143+
await render({ ...entity, uri: 'urn:entity:shared', trustLevel: 'shared', layers: new Set(['shared']) });
144+
expect(query('.v10-ka-label').textContent).toBe('Entity');
145+
146+
await render({ ...entity, uri: 'urn:entity:verified', trustLevel: 'verified', layers: new Set(['verified']) });
147+
expect(query('.v10-ka-label').textContent).toBe('Knowledge Asset');
148+
});
149+
109150
it('renders provenance timestamps in the event header without requiring an agent', async () => {
110151
const at = '2026-05-23T12:34:00.000Z';
111152

0 commit comments

Comments
 (0)