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:
@@ -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', () => {
|
||||
|
||||
@@ -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<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()
|
||||
|
||||
// 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<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)
|
||||
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 }
|
||||
}
|
||||
|
||||
14
src/i18n.ts
14
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<Key, string> = {
|
||||
'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',
|
||||
|
||||
@@ -13,7 +13,7 @@ import { locale, t, type Key } from '../i18n'
|
||||
import { ContentCard, Preview } from './Results'
|
||||
|
||||
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)
|
||||
@@ -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 }) {
|
||||
</g>
|
||||
<g>
|
||||
{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 (
|
||||
<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' : ''}`}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user