From 28a08a04b206f58691788a5ccc9388cda438b99a Mon Sep 17 00:00:00 2001 From: Rassl Date: Wed, 23 Sep 2026 20:55:29 +0400 Subject: [PATCH 1/3] feat: plain-text ontology digest for agents ontologyDigest(schemas, edges, {domains}) renders the CHILD_OF tree (indented, sorted, own attributes with ? for optional) and the relationship triples as a compact deterministic text block meant for an LLM prompt. Generated from the live schema payload so it cannot drift from the graph. --- src/lib/__tests__/ontology-digest.test.ts | 70 +++++++++++++++ src/lib/ontology-digest.ts | 105 ++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/lib/__tests__/ontology-digest.test.ts create mode 100644 src/lib/ontology-digest.ts diff --git a/src/lib/__tests__/ontology-digest.test.ts b/src/lib/__tests__/ontology-digest.test.ts new file mode 100644 index 0000000..1a33e82 --- /dev/null +++ b/src/lib/__tests__/ontology-digest.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest" +import { ontologyDigest } from "@/lib/ontology-digest" +import type { SchemaNode, SchemaEdge } from "@/lib/schema-types" + +const node = (type: string, parent: string, domain: string, attrs: Array<[string, boolean]> = []): SchemaNode => ({ + ref_id: type, + type, + parent, + domain, + color: "#000", + node_key: `${type.toLowerCase()}-id`, + attributes: attrs.map(([key, required]) => ({ key, type: "string", required })), +}) + +const schemas: SchemaNode[] = [ + node("Thing", "", "entity"), + node("SysComponent", "Thing", "SystemMap", [["id", true], ["description", false]]), + node("SysServiceComponent", "SysComponent", "SystemMap"), + node("SysBackendService", "SysServiceComponent", "SystemMap"), + node("SysApplicationComponent", "SysComponent", "SystemMap"), + node("SysResource", "Thing", "SystemMap"), + node("System", "Thing", "Infosec", [["system_id", true]]), + node("Person", "Thing", "Entity"), +] +const edges: SchemaEdge[] = [ + { ref_id: "e1", source: "System", target: "SysComponent", edge_type: "CONTAINS", source_type: "System", target_type: "SysComponent" }, + { ref_id: "e2", source: "SysComponent", target: "SysResource", edge_type: "RUNS_ON", source_type: "SysComponent", target_type: "SysResource" }, + { ref_id: "e3", source: "SysComponent", target: "Thing", edge_type: "CHILD_OF", source_type: "SysComponent", target_type: "Thing" }, + { ref_id: "e4", source: "Person", target: "Person", edge_type: "KNOWS", source_type: "Person", target_type: "Person" }, + { ref_id: "e5", source: "SysComponent", target: "SysResource", edge_type: "CONNECTS_TO", source_type: "SysComponent", target_type: "SysResource", attributes: { since: "?datetime" } }, +] + +describe("ontologyDigest", () => { + it("indents by CHILD_OF depth, sorted by name, with own attributes", () => { + const text = ontologyDigest(schemas, edges, { domains: ["systemmap"] }) + expect(text).toContain("SysComponent [systemmap]: id, ?description\n SysApplicationComponent\n SysServiceComponent\n SysBackendService\n") + expect(text).toContain("SysResource [systemmap]\n") + expect(text).not.toContain("Person") + expect(text).not.toContain("Thing [") + }) + + it("keeps relationships with one endpoint in the selection and drops CHILD_OF", () => { + const text = ontologyDigest(schemas, edges, { domains: ["systemmap"] }) + expect(text).toContain("System CONTAINS SysComponent") + expect(text).toContain("SysComponent CONNECTS_TO SysResource {since: ?datetime}") + expect(text).not.toContain("CHILD_OF SysComponent") + expect(text).not.toContain("KNOWS") + expect(text).toContain("5 types, 3 relationships") + }) + + it("prints a type whose parent is outside the selection as an annotated root", () => { + const text = ontologyDigest(schemas, edges, { domains: ["systemmap"], attributes: false }) + // Restrict to a subtree by excluding the root domain: nothing to annotate here, + // so simulate by selecting a child alone. + const sub = ontologyDigest( + schemas.filter((s) => s.type !== "SysComponent"), + edges, + { domains: ["systemmap"], attributes: false } + ) + expect(sub).toContain("SysServiceComponent [systemmap] (extends SysComponent)\n SysBackendService") + expect(text).not.toContain(": id") + }) + + it("covers every domain when none is given", () => { + const text = ontologyDigest(schemas, edges) + expect(text).toContain("(domains: entity, infosec, systemmap)") + expect(text).toContain("Person [entity]") + expect(text).toContain("Person KNOWS Person") + }) +}) diff --git a/src/lib/ontology-digest.ts b/src/lib/ontology-digest.ts new file mode 100644 index 0000000..520608c --- /dev/null +++ b/src/lib/ontology-digest.ts @@ -0,0 +1,105 @@ +// Agent-facing text rendering of the ontology: the CHILD_OF tree and the +// relationship triples as a compact, deterministic plain-text digest. Meant to +// be pasted into an LLM prompt (or copied from the ontology page), so it +// optimises for tokens and unambiguity over structure. Generated from the live +// schema payload, never from a seed script or a doc, so it cannot drift. + +import type { SchemaNode, SchemaEdge } from "./schema-types" +import { domainKeyOf, ROOT_TYPE } from "./schema-domains" + +export interface OntologyDigestOptions { + /** Lowercased domain keys to include. Omit for every domain. */ + domains?: readonly string[] + /** Print each type's own attributes after its name (default true). */ + attributes?: boolean +} + +function attrLine(s: SchemaNode): string { + const own = (s.attributes ?? []).filter((a) => a.key) + if (own.length === 0) return "" + const parts = own.map((a) => (a.required ? a.key : `?${a.key}`)) + return `: ${parts.join(", ")}` +} + +function edgeAttrs(e: SchemaEdge): string { + const entries = Object.entries(e.attributes ?? {}) + if (entries.length === 0) return "" + return ` {${entries.map(([k, t]) => `${k}: ${t}`).join(", ")}}` +} + +/** + * Render schemas + edges as text. Types are indented by CHILD_OF depth + * (specific under broader, sorted by name at each level); a type whose parent + * is outside the selected domains is printed as a root annotated with what it + * extends. Relationships are listed once per (source, edge, target) triple and + * apply to subtypes on both ends by inheritance, which the header states so the + * reader does not expect one line per subtype. + */ +export function ontologyDigest( + schemas: SchemaNode[], + edges: SchemaEdge[], + opts: OntologyDigestOptions = {} +): string { + const wantDomain = opts.domains ? new Set(opts.domains.map((d) => d.toLowerCase())) : null + const showAttrs = opts.attributes ?? true + + const included = schemas.filter( + (s) => s.type && s.type !== ROOT_TYPE && (!wantDomain || wantDomain.has(domainKeyOf(s))) + ) + const byType = new Map(included.map((s) => [s.type, s])) + const children = new Map() + const roots: SchemaNode[] = [] + for (const s of included) { + if (s.parent && byType.has(s.parent)) { + const list = children.get(s.parent) ?? [] + list.push(s) + children.set(s.parent, list) + } else { + roots.push(s) + } + } + const byName = (a: SchemaNode, b: SchemaNode) => a.type.localeCompare(b.type) + roots.sort(byName) + for (const list of children.values()) list.sort(byName) + + const lines: string[] = [] + const seen = new Set() + const walk = (s: SchemaNode, depth: number) => { + if (seen.has(s.type)) return // defensive: a CHILD_OF cycle must not loop + seen.add(s.type) + const pad = " ".repeat(depth) + const ext = depth === 0 && s.parent && s.parent !== ROOT_TYPE ? ` (extends ${s.parent})` : "" + const dom = depth === 0 ? ` [${domainKeyOf(s)}]` : "" + lines.push(`${pad}${s.type}${dom}${ext}${showAttrs ? attrLine(s) : ""}`) + for (const c of children.get(s.type) ?? []) walk(c, depth + 1) + } + + const rels = edges + .filter((e) => e.edge_type && e.edge_type !== "CHILD_OF") + .filter((e) => { + const src = e.source_type ?? "" + const tgt = e.target_type ?? "" + return byType.has(src) || byType.has(tgt) + }) + .map((e) => `${e.source_type} ${e.edge_type} ${e.target_type}${edgeAttrs(e)}`) + const relLines = Array.from(new Set(rels)).sort() + + const domainList = wantDomain + ? Array.from(wantDomain).sort().join(", ") + : Array.from(new Set(included.map(domainKeyOf))).sort().join(", ") + + const out: string[] = [] + out.push( + `# Ontology digest: ${included.length} types, ${relLines.length} relationships (domains: ${domainList || "none"})` + ) + out.push("") + out.push( + "## Types (indent = CHILD_OF, specific under broader; [domain] on roots; own attributes after ':', '?' = optional; inherited attributes are implied by the tree)" + ) + for (const r of roots) walk(r, 0) + out.push(...lines) + out.push("") + out.push("## Relationships (source EDGE target; each applies to subtypes of both ends by inheritance)") + for (const l of relLines) out.push(l) + return out.join("\n") + "\n" +} From 45ca3103695466592c66ab483be81d207e946791 Mon Sep 17 00:00:00 2001 From: Rassl Date: Wed, 23 Sep 2026 20:56:33 +0400 Subject: [PATCH 2/3] docs: state inherited attributes once in the digest header --- src/lib/ontology-digest.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/ontology-digest.ts b/src/lib/ontology-digest.ts index 520608c..8e9947b 100644 --- a/src/lib/ontology-digest.ts +++ b/src/lib/ontology-digest.ts @@ -94,7 +94,8 @@ export function ontologyDigest( ) out.push("") out.push( - "## Types (indent = CHILD_OF, specific under broader; [domain] on roots; own attributes after ':', '?' = optional; inherited attributes are implied by the tree)" + "## Types (indent = CHILD_OF, specific under broader; [domain] on roots; own attributes after ':', '?' = optional; " + + "subtypes inherit their ancestors' attributes, and every type inherits name and ?description from Thing)" ) for (const r of roots) walk(r, 0) out.push(...lines) From 7dd0875e81d925ed863bb24b6ad17e5bb88d4ac8 Mon Sep 17 00:00:00 2001 From: Rassl Date: Wed, 23 Sep 2026 21:00:43 +0400 Subject: [PATCH 3/3] feat: outline view on the ontology page Third view next to network and hierarchy: the domain-filtered types grouped by domain and nested by parent as a collapsible tree, with search (name or node_key, ancestors kept, auto-expand, highlight), direct/total child counts, expand/collapse all, a relationships box for the selected type that includes edges inherited from ancestors, and a Copy-for-agent button that copies the ontologyDigest text. Selection shares the page's selectedId so the TypeEditor opens as from the other views. The view choice is now persisted in localStorage (ontology:view) via useSyncExternalStore. --- src/app/admin/ontology/ontology-outline.tsx | 359 ++++++++++++++++++++ src/app/admin/ontology/page.tsx | 67 +++- src/lib/__tests__/ontology-outline.test.ts | 172 ++++++++++ src/lib/ontology-outline.ts | 240 +++++++++++++ 4 files changed, 829 insertions(+), 9 deletions(-) create mode 100644 src/app/admin/ontology/ontology-outline.tsx create mode 100644 src/lib/__tests__/ontology-outline.test.ts create mode 100644 src/lib/ontology-outline.ts diff --git a/src/app/admin/ontology/ontology-outline.tsx b/src/app/admin/ontology/ontology-outline.tsx new file mode 100644 index 0000000..5450ff4 --- /dev/null +++ b/src/app/admin/ontology/ontology-outline.tsx @@ -0,0 +1,359 @@ +"use client" + +import { memo, useCallback, useMemo, useRef, useState, type ReactNode } from "react" +import { Check, ChevronDown, ChevronRight, Copy, Search } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import type { SchemaNode, SchemaEdge } from "@/lib/schema-types" +import { ontologyDigest } from "@/lib/ontology-digest" +import { + allExpandableRefs, + buildOutlineModel, + defaultExpandedRefs, + outlineAncestors, + relationshipsForType, + searchOutline, + type OutlineDomain, + type OutlineNode, +} from "@/lib/ontology-outline" + +interface Props { + /** Already domain-filtered by the page, like the other two views. */ + schemas: SchemaNode[] + edges: SchemaEdge[] + selectedId: string | null + onSelect: (id: string) => void +} + +const INDENT_PX = 16 +const COPIED_MS = 1500 + +/** Wrap every occurrence of `query` (already lowercased) in . */ +function highlight(text: string, query: string): ReactNode { + if (!query) return text + const lower = text.toLowerCase() + const parts: ReactNode[] = [] + let from = 0 + let at = lower.indexOf(query, from) + while (at !== -1) { + if (at > from) parts.push(text.slice(from, at)) + parts.push( + + {text.slice(at, at + query.length)} + + ) + from = at + query.length + at = lower.indexOf(query, from) + } + if (from < text.length) parts.push(text.slice(from)) + return parts +} + +type Row = { kind: "domain"; domain: OutlineDomain } | { kind: "node"; node: OutlineNode } + +interface NodeRowProps { + node: OutlineNode + expanded: boolean + selected: boolean + matched: boolean + query: string + onToggle: (refId: string) => void + onSelect: (refId: string) => void +} + +const NodeRow = memo(function NodeRow({ node, expanded, selected, matched, query, onToggle, onSelect }: NodeRowProps) { + const hasChildren = node.directCount > 0 + return ( +
onSelect(node.refId)} + className={`group flex w-full cursor-pointer items-center gap-1.5 rounded-md py-1 pr-2 text-left transition-colors ${ + selected + ? "bg-primary/10 text-foreground" + : matched + ? "text-foreground hover:bg-muted/50" + : "text-muted-foreground hover:bg-muted/50 hover:text-foreground" + }`} + style={{ paddingLeft: 8 + node.depth * INDENT_PX }} + > + {hasChildren ? ( + + ) : ( + + )} + {highlight(node.type, query)} + {node.nodeKey && ( + + {highlight(node.nodeKey, query)} + + )} + {hasChildren && ( + + {node.directCount} direct · {node.totalCount} total + + )} +
+ ) +}) + +interface DomainRowProps { + domain: OutlineDomain + collapsed: boolean + onToggle: (key: string) => void +} + +const DomainRow = memo(function DomainRow({ domain, collapsed, onToggle }: DomainRowProps) { + return ( + + ) +}) + +export const OntologyOutline = memo(function OntologyOutline({ schemas, edges, selectedId, onSelect }: Props) { + const [query, setQuery] = useState("") + // null = the model's default expansion; a Set once the user has touched it. + const [expanded, setExpanded] = useState | null>(null) + const [collapsedDomains, setCollapsedDomains] = useState>(() => new Set()) + const [copied, setCopied] = useState(false) + const copiedTimer = useRef | null>(null) + const fallbackRef = useRef(null) + + const model = useMemo(() => buildOutlineModel(schemas), [schemas]) + const search = useMemo(() => searchOutline(model, query), [model, query]) + const baseExpanded = useMemo(() => expanded ?? defaultExpandedRefs(model), [expanded, model]) + + // Reveal a selection made elsewhere (sidebar, another view) by opening its + // ancestors. Done as a render-time state adjustment keyed on selectedId so + // the user can still collapse those ancestors afterwards. + const [revealedFor, setRevealedFor] = useState(null) + if (selectedId !== revealedFor) { + setRevealedFor(selectedId) + if (selectedId && model.byRef.has(selectedId)) { + const ancestors = outlineAncestors(model, selectedId) + if (ancestors.some((a) => !baseExpanded.has(a))) { + const next = new Set(baseExpanded) + for (const a of ancestors) next.add(a) + setExpanded(next) + } + } + } + + const rows = useMemo(() => { + const searching = search.query.length > 0 + const out: Row[] = [] + const visit = (node: OutlineNode) => { + if (searching && !search.visible.has(node.refId)) return + out.push({ kind: "node", node }) + const open = baseExpanded.has(node.refId) || (searching && search.expanded.has(node.refId)) + if (!open) return + for (const c of node.children) visit(c) + } + for (const d of model.domains) { + if (searching && !d.roots.some((r) => search.visible.has(r.refId))) continue + out.push({ kind: "domain", domain: d }) + if (collapsedDomains.has(d.key) && !searching) continue + for (const r of d.roots) visit(r) + } + return out + }, [model, search, baseExpanded, collapsedDomains]) + + const isOpen = useCallback( + (refId: string) => baseExpanded.has(refId) || (search.query.length > 0 && search.expanded.has(refId)), + [baseExpanded, search] + ) + + // While a search is active, ancestors of matches stay open regardless of + // this set (see `isOpen`), so toggling only affects the persisted choice. + const handleToggle = useCallback( + (refId: string) => { + setExpanded((prev) => { + const next = new Set(prev ?? defaultExpandedRefs(model)) + if (next.has(refId)) next.delete(refId) + else next.add(refId) + return next + }) + }, + [model] + ) + const handleToggleDomain = useCallback((key: string) => { + setCollapsedDomains((prev) => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }, []) + const handleExpandAll = useCallback(() => { + setExpanded(allExpandableRefs(model)) + setCollapsedDomains(new Set()) + }, [model]) + const handleCollapseAll = useCallback(() => { + setExpanded(new Set()) + }, []) + + const handleCopy = useCallback(() => { + const text = ontologyDigest(schemas, edges) + const done = () => { + setCopied(true) + if (copiedTimer.current) clearTimeout(copiedTimer.current) + copiedTimer.current = setTimeout(() => setCopied(false), COPIED_MS) + } + const fallback = () => { + const ta = fallbackRef.current + if (!ta) return + ta.value = text + ta.focus() + ta.select() + try { + document.execCommand("copy") + } catch { + // the text stays selected in the textarea for a manual copy + } + done() + } + try { + navigator.clipboard.writeText(text).then(done, fallback) + } catch { + fallback() + } + }, [schemas, edges]) + + const selectedNode = selectedId ? model.byRef.get(selectedId) : undefined + const relationships = useMemo( + () => (selectedNode ? relationshipsForType(selectedNode.type, model.byType, edges) : []), + [selectedNode, model, edges] + ) + + const typeCount = model.byRef.size + const matchCount = search.matched.size + + return ( +
+ {/* Toolbar */} +
+
+ + setQuery(e.target.value)} + placeholder="Filter by type or key..." + aria-label="Filter outline" + className="h-8 pl-8 text-sm" + /> +
+ + {search.query ? `${matchCount} of ${typeCount}` : `${typeCount} types · ${model.domains.length} domains`} + +
+ + + +
+