Show only the knowledge base and content in the knowledge graph

The graph now represents the agent's Brain: its documents and the
content made from them. Work in progress, failed runs, research
suggestions and schedules are status, so they are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-29 11:51:04 +07:00
parent 643c3c39f9
commit bdae539e9e
5 changed files with 39 additions and 58 deletions

View File

@@ -1,31 +1,30 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { Source } from '../api/engine' import type { Source } from '../api/engine'
import { buildGraph, HEIGHT, WIDTH } from './graph' import { buildGraph, HEIGHT, WIDTH } from './graph'
import type { FoundSource, Material } from './results' import type { Material } from './results'
import type { Schedule } from './schedules'
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[]) => const material = (id: number, status: Material['status'], sources: string[]) =>
({ id, agentId: 7, kind: 'summary', status, planTitle: `Lesson ${id}`, sources }) as Material ({ 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', () => { describe('buildGraph', () => {
const data = { const data = {
docs: [doc(1, 'Onboarding'), doc(2, 'Safety')], docs: [doc(1, 'Onboarding'), doc(2, 'Safety'), doc(3, 'Broken', 'failed')],
materials: [material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', [])], materials: [
sources: [{ id: 5, agentId: 7, title: 'Article', documentId: 2 } as FoundSource], material(10, 'accepted', ['Onboarding.pdf', 'Safety']), material(11, 'generating', []),
schedules: [schedule], material(12, 'failed', ['Safety']), material(13, 'ready', ['Safety']),
],
} }
const graph = buildGraph(data, c => c) const graph = buildGraph(data, c => c)
it('puts results in progress in their own cluster', () => { it('holds only the knowledge base and the content made from it', () => {
expect(graph.nodes.find(n => n.id === 'mat-7-10')?.cluster).toBe('made') expect(graph.nodes.filter(n => !n.hub).map(n => `${n.cluster}:${n.id}`).sort())
expect(graph.nodes.find(n => n.id === 'mat-7-11')?.cluster).toBe('working') .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}`) 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', () => { it('lays out the same data the same way, inside the canvas', () => {

View File

@@ -1,13 +1,13 @@
// The Production knowledge graph: every Brain document, result, found source and schedule as a node, // The Production knowledge graph: what the agent's Brain holds, its knowledge base (the documents)
// grouped around one hub per cluster and linked where the engine says they are related (a result's // and the content made from it, grouped around one hub per cluster and linked where a piece of
// sources, the schedule that made it, the document a found source became). Laid out once with a // content names a document as its source. Work in progress, failed runs and schedules are status,
// small seeded force simulation, so the same data always draws the same picture. // 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 type { Source } from '../api/engine'
import { isWorking, type FoundSource, type Material } from './results' import { isWorking, type Material } from './results'
import type { Schedule } from './schedules'
export type Cluster = 'sources' | 'made' | 'working' | 'research' | 'schedules' export type Cluster = 'sources' | 'made'
export const CLUSTERS: Cluster[] = ['sources', 'made', 'working', 'research', 'schedules'] export const CLUSTERS: Cluster[] = ['sources', 'made']
export interface GraphNode { export interface GraphNode {
id: string id: string
@@ -27,13 +27,16 @@ export const HEIGHT = 640
// Where each cluster settles, around the middle of the canvas. // Where each cluster settles, around the middle of the canvas.
const ANCHOR: Record<Cluster, [number, number]> = { const ANCHOR: Record<Cluster, [number, number]> = {
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() 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( 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, hubLabel: (c: Cluster) => string,
): Graph { ): Graph {
const nodes: GraphNode[] = CLUSTERS.map(c => ({ id: `hub-${c}`, cluster: c, label: hubLabel(c), hub: true, x: 0, y: 0 })) 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. // Results name their sources by title, so documents are found by title or file name.
const docByName = new Map<string, string>() const docByName = new Map<string, string>()
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) add(`doc-${d.docId}`, 'sources', d.title)
docByName.set(norm(d.title), `doc-${d.docId}`) docByName.set(norm(d.title), `doc-${d.docId}`)
docByName.set(norm(d.originalName), `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) for (const m of materials.filter(inBrain)) {
const agents = new Set(schedules.map(s => `agent-${s.agent.id}`))
for (const m of materials) {
const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}` const id = `${m.kind === 'video' ? 'video' : 'mat'}-${m.agentId}-${m.id}`
add(id, isWorking(m) ? 'working' : 'made', m.planTitle ?? m.kind) add(id, 'made', m.planTitle ?? m.kind)
if (agents.has(`agent-${m.agentId}`)) link(id, `agent-${m.agentId}`)
for (const title of new Set(m.sources)) { for (const title of new Set(m.sources)) {
const doc = docByName.get(norm(title)) const doc = docByName.get(norm(title))
if (doc) link(id, doc) 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) layout(nodes, links)
return { nodes, links, width: WIDTH, height: HEIGHT } return { nodes, links, width: WIDTH, height: HEIGHT }
} }

View File

@@ -77,11 +77,8 @@ const en = {
'prod.exitFullscreen': 'Exit full screen', 'prod.exitFullscreen': 'Exit full screen',
'prod.loadingGraph': 'Drawing the knowledge graph…', 'prod.loadingGraph': 'Drawing the knowledge graph…',
'prod.graphLabel': 'Knowledge graph of {nodes} nodes', 'prod.graphLabel': 'Knowledge graph of {nodes} nodes',
'prod.hub.sources': 'Sources', 'prod.hub.sources': 'Knowledge base',
'prod.hub.made': 'Made', 'prod.hub.made': 'Content',
'prod.hub.working': 'In progress',
'prod.hub.research': 'Research',
'prod.hub.schedules': 'Repeats',
'prod.nodes': 'Nodes', 'prod.nodes': 'Nodes',
'prod.nodesSub': 'in this Brain', 'prod.nodesSub': 'in this Brain',
'prod.links': 'Links', 'prod.links': 'Links',
@@ -537,11 +534,8 @@ const id: Record<Key, string> = {
'prod.exitFullscreen': 'Keluar layar penuh', 'prod.exitFullscreen': 'Keluar layar penuh',
'prod.loadingGraph': 'Menggambar knowledge graph…', 'prod.loadingGraph': 'Menggambar knowledge graph…',
'prod.graphLabel': 'Knowledge graph berisi {nodes} node', 'prod.graphLabel': 'Knowledge graph berisi {nodes} node',
'prod.hub.sources': 'Sumber', 'prod.hub.sources': 'Basis pengetahuan',
'prod.hub.made': 'Dibuat', 'prod.hub.made': 'Konten',
'prod.hub.working': 'Sedang dibuat',
'prod.hub.research': 'Riset',
'prod.hub.schedules': 'Berulang',
'prod.nodes': 'Node', 'prod.nodes': 'Node',
'prod.nodesSub': 'di Otak ini', 'prod.nodesSub': 'di Otak ini',
'prod.links': 'Tautan', 'prod.links': 'Tautan',

View File

@@ -13,7 +13,7 @@ import { locale, t, type Key } from '../i18n'
import { ContentCard, Preview } from './Results' import { ContentCard, Preview } from './Results'
const HUB_LABEL: Record<Cluster, Key> = { const HUB_LABEL: Record<Cluster, Key> = {
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) // 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. // 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 lms = materials.filter(m => m.status === 'accepted' && m.kind === 'scorm')
const graph = useMemo( const graph = useMemo(
() => (docs.data && results.data && schedules.data () => (docs.data && results.data ? buildGraph({ docs: docs.data, materials: results.data.materials }, c => t(HUB_LABEL[c])) : null),
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c])) [docs.data, results.data],
: null),
[docs.data, results.data, schedules.data],
) )
const num = (n: number, ready: boolean, unit: Key) => (ready const num = (n: number, ready: boolean, unit: Key) => (ready
@@ -297,7 +295,7 @@ function KnowledgeGraph({ graph }: { graph: Graph }) {
</g> </g>
<g> <g>
{graph.nodes.map((n, i) => { {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 ( return (
<g key={n.id} ref={el => { nodeEls.current[i] = el }} <g key={n.id} ref={el => { 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' : ''}`} className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}

View File

@@ -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} .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)}} @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; 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)} 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} .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 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) 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: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} .c-sources{--c:var(--g-sources)}.c-made{--c:var(--g-made)}
@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)}
.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)); .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)} 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{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} .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{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; .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)} background:rgba(10,22,40,.72);border:1px solid rgba(255,255,255,.1);backdrop-filter:blur(10px)}