diff --git a/src/express/graph.test.ts b/src/express/graph.test.ts index 6773ccf..aa02236 100644 --- a/src/express/graph.test.ts +++ b/src/express/graph.test.ts @@ -1,31 +1,30 @@ import { describe, expect, it } from 'vitest' import type { Source } from '../api/engine' import { buildGraph, HEIGHT, WIDTH } from './graph' -import type { FoundSource, Material } from './results' -import type { Schedule } from './schedules' +import type { Material } from './results' -const doc = (docId: number, title: string) => ({ docId, title, originalName: `${title}.pdf` }) as Source +const doc = (docId: number, title: string, status = 'completed') => ({ docId, title, originalName: `${title}.pdf`, status }) as Source const material = (id: number, status: Material['status'], sources: string[]) => ({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material -const schedule = { agent: { id: 7, name: 'Express · 2 summary per week' } } as Schedule describe('buildGraph', () => { const data = { - docs: [doc(1, 'Onboarding'), doc(2, 'Safety')], - materials: [material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', [])], - sources: [{ id: 5, agentId: 7, title: 'Article', documentId: 2 } as FoundSource], - schedules: [schedule], + docs: [doc(1, 'Onboarding'), doc(2, 'Safety'), doc(3, 'Broken', 'failed')], + materials: [ + material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', []), + material(12, 'failed', ['Safety']), material(13, 'ready', ['Safety']), + ], } const graph = buildGraph(data, c => c) - it('puts results in progress in their own cluster', () => { - expect(graph.nodes.find(n => n.id === 'mat-7-10')?.cluster).toBe('made') - expect(graph.nodes.find(n => n.id === 'mat-7-11')?.cluster).toBe('working') + it('holds only the knowledge base and the content made from it', () => { + expect(graph.nodes.filter(n => !n.hub).map(n => `${n.cluster}:${n.id}`).sort()) + .toEqual(['made:mat-7-10', 'made:mat-7-13', 'sources:doc-1', 'sources:doc-2']) }) - it('links results to their sources and schedule, and found sources to their document', () => { + it('links content to its sources', () => { const cross = graph.links.filter(l => !l.hub).map(l => `${l.source}>${l.target}`) - expect(cross).toEqual(expect.arrayContaining(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-10>agent-7', 'found-7-5>doc-2'])) + expect(cross.sort()).toEqual(['mat-7-10>doc-1', 'mat-7-10>doc-2', 'mat-7-13>doc-2']) }) it('lays out the same data the same way, inside the canvas', () => { diff --git a/src/express/graph.ts b/src/express/graph.ts index 22e7a41..6343ad5 100644 --- a/src/express/graph.ts +++ b/src/express/graph.ts @@ -1,13 +1,13 @@ -// The Production knowledge graph: every Brain document, result, found source and schedule as a node, -// grouped around one hub per cluster and linked where the engine says they are related (a result's -// sources, the schedule that made it, the document a found source became). Laid out once with a -// small seeded force simulation, so the same data always draws the same picture. +// The Production knowledge graph: what the agent's Brain holds, its knowledge base (the documents) +// and the content made from it, grouped around one hub per cluster and linked where a piece of +// content names a document as its source. Work in progress, failed runs and schedules are status, +// not knowledge, so they stay out. Laid out once with a small seeded force simulation, so the same +// data always draws the same picture. import type { Source } from '../api/engine' -import { isWorking, type FoundSource, type Material } from './results' -import type { Schedule } from './schedules' +import { isWorking, type Material } from './results' -export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules' -export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules'] +export type Cluster = 'sources' | 'made' +export const CLUSTERS: Cluster[] = ['sources', 'made'] export interface GraphNode { id: string @@ -27,13 +27,16 @@ export const HEIGHT = 640 // Where each cluster settles, around the middle of the canvas. const ANCHOR: Record = { - sources: [560, 360], made: [450, 430], working: [330, 300], research: [440, 200], schedules: [700, 230], + sources: [580, 320], made: [420, 320], } const norm = (s: string) => s.trim().toLowerCase() +// Content still being made, failed or turned down is not part of the Brain. +const inBrain = (m: Material) => !isWorking(m) && m.status !== 'failed' && m.status !== 'rejected' + export function buildGraph( - { docs, materials, sources, schedules }: { docs: Source[]; materials: Material[]; sources: FoundSource[]; schedules: Schedule[] }, + { docs, materials }: { docs: Source[]; materials: Material[] }, hubLabel: (c: Cluster) => string, ): Graph { const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 })) @@ -46,30 +49,19 @@ export function buildGraph( // Results name their sources by title, so documents are found by title or file name. const docByName = new Map() - for (const d of docs) { + for (const d of docs.filter(d => d.status !== 'failed' && d.knowledge !== 'failed')) { add(`doc-${d.docId}`, 'sources', d.title) docByName.set(norm(d.title), `doc-${d.docId}`) docByName.set(norm(d.originalName), `doc-${d.docId}`) } - for (const s of schedules) add(`agent-${s.agent.id}`, 'schedules', s.agent.name.split(' · ').slice(1).join(' · ') || s.agent.name) - const agents = new Set(schedules.map(s => `agent-${s.agent.id}`)) - - for (const m of materials) { + for (const m of materials.filter(inBrain)) { const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}` - add(id, isWorking(m) ? 'working' : 'made', m.planTitle ?? m.kind) - if (agents.has(`agent-${m.agentId}`)) link(id, `agent-${m.agentId}`) + add(id, 'made', m.planTitle ?? m.kind) for (const title of new Set(m.sources)) { const doc = docByName.get(norm(title)) if (doc) link(id, doc) } } - for (const s of sources) { - const id = `found-${s.agentId}-${s.id}` - add(id, 'research', s.title) - if (agents.has(`agent-${s.agentId}`)) link(id, `agent-${s.agentId}`) - if (s.documentId != null && docs.some(d => d.docId === s.documentId)) link(id, `doc-${s.documentId}`) - } - layout(nodes, links) return { nodes, links, width: WIDTH, height: HEIGHT } } diff --git a/src/i18n.ts b/src/i18n.ts index 8d65ab2..31281ad 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -77,11 +77,8 @@ const en = { 'prod.exitFullscreen': 'Exit full screen', 'prod.loadingGraph': 'Drawing the knowledge graph…', 'prod.graphLabel': 'Knowledge graph of {nodes} nodes', - 'prod.hub.sources': 'Sources', - 'prod.hub.made': 'Made', - 'prod.hub.working': 'In progress', - 'prod.hub.research': 'Research', - 'prod.hub.schedules': 'Repeats', + 'prod.hub.sources': 'Knowledge base', + 'prod.hub.made': 'Content', 'prod.nodes': 'Nodes', 'prod.nodesSub': 'in this Brain', 'prod.links': 'Links', @@ -537,11 +534,8 @@ const id: Record = { 'prod.exitFullscreen': 'Keluar layar penuh', 'prod.loadingGraph': 'Menggambar knowledge graph…', 'prod.graphLabel': 'Knowledge graph berisi {nodes} node', - 'prod.hub.sources': 'Sumber', - 'prod.hub.made': 'Dibuat', - 'prod.hub.working': 'Sedang dibuat', - 'prod.hub.research': 'Riset', - 'prod.hub.schedules': 'Berulang', + 'prod.hub.sources': 'Basis pengetahuan', + 'prod.hub.made': 'Konten', 'prod.nodes': 'Node', 'prod.nodesSub': 'di Otak ini', 'prod.links': 'Tautan', diff --git a/src/pages/Production.tsx b/src/pages/Production.tsx index 5812ca2..4e3eb08 100644 --- a/src/pages/Production.tsx +++ b/src/pages/Production.tsx @@ -13,7 +13,7 @@ import { locale, t, type Key } from '../i18n' import { ContentCard, Preview } from './Results' const HUB_LABEL: Record = { - sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules', + sources: 'prod.hub.sources', made: 'prod.hub.made', } // Uses the Fullscreen API on the whole stage (graph + panel); where it is missing (iPhone Safari) @@ -67,10 +67,8 @@ export function ProductionPage() { // No LMS is connected yet: an approved SCORM module is the package that goes into one. const lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm') const graph = useMemo( - () => (docs.data && results.data && schedules.data - ? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c])) - : null), - [docs.data, results.data, schedules.data], + () => (docs.data && results.data ? buildGraph({ docs: docs.data, materials: results.data.materials }, c => t(HUB_LABEL[c])) : null), + [docs.data, results.data], ) const num = (n: number, ready: boolean, unit: Key) => (ready @@ -297,7 +295,7 @@ function KnowledgeGraph({ graph }: { graph: Graph }) { {graph.nodes.map((n, i) => { - const r = n.hub ? 9 : n.cluster === 'schedules' ? 5.5 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7) + const r = n.hub ? 9 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7) return ( { nodeEls.current[i] = el }} className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`} diff --git a/src/styles/theme.css b/src/styles/theme.css index c6e629e..007a733 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -304,7 +304,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)} .live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 0 0 rgba(74,222,128,.6);animation:live-pulse 1.8s ease-out infinite;flex-shrink:0} @keyframes live-pulse{70%{box-shadow:0 0 0 8px rgba(74,222,128,0)}100%{box-shadow:0 0 0 0 rgba(74,222,128,0)}} -.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd;--g-research:#84caff;--g-schedules:#fdb022; +.graph-stage{--g-sources:#6ce9a6;--g-made:#fde272;--g-working:#c3b5fd; position:relative;min-height:640px;border-radius:22px;overflow:hidden;color:#e4e7ec; background:radial-gradient(ellipse at 45% 50%,#0f2a4d 0%,#0a1628 55%,#060d1a 100%);box-shadow:0 24px 60px -28px rgba(16,24,40,.6)} .graph-svg{position:absolute;inset:0;width:calc(100% - 300px);height:100%;display:block} @@ -341,9 +341,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)} .graph-node text{fill:#fff;font-size:13px;font-weight:700;letter-spacing:.01em;text-anchor:middle;paint-order:stroke;stroke:#08142a;stroke-width:4px;stroke-linejoin:round;pointer-events:none} .graph-node:not(.hub) text{font-size:11.5px;font-weight:600;opacity:0;transform:translateY(4px);transition:opacity .2s,transform .2s} .graph-node:not(.hub):is(.hover,.sel) text{opacity:1;transform:none} -.graph-node.c-working:not(.hub) .core{animation:node-pulse 1.6s ease-in-out infinite} -@keyframes node-pulse{50%{opacity:.4}} -.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}.c-working{--c:var(--g-working)}.c-research{--c:var(--g-research)}.c-schedules{--c:var(--g-schedules)} +.c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)} .graph-note{position:absolute;top:16px;left:16px;z-index:2;display:flex;align-items:center;gap:8px;max-width:min(460px,calc(100% - 340px)); padding:8px 14px;border-radius:999px;background:rgba(10,22,40,.8);border:1px solid rgba(255,255,255,.12);font-size:12.5px;backdrop-filter:blur(8px)} @@ -354,7 +352,7 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)} .graph-legend{position:absolute;left:16px;bottom:16px;z-index:2;display:flex;flex-wrap:wrap;gap:6px 14px;list-style:none;margin:0;padding:0;font-size:11.5px;color:#98a2b3} .graph-legend li{display:flex;align-items:center;gap:6px} .node-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c);box-shadow:0 0 6px var(--c);flex-shrink:0} -.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)}.node-dot.research{--c:var(--g-research)}.node-dot.schedules{--c:var(--g-schedules)} +.node-dot.sources{--c:var(--g-sources)}.node-dot.made{--c:var(--g-made)}.node-dot.working{--c:var(--g-working)} .graph-panel{position:absolute;top:16px;right:16px;bottom:16px;width:284px;z-index:2;overflow:auto;padding:14px;border-radius:18px; background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)}