Squashed 'ai-context/workbench-ui/' content from commit 32e36a5c

git-subtree-dir: ai-context/workbench-ui
git-subtree-split: 32e36a5c2131e429a7081cfaf67dabad3193cda3
This commit is contained in:
elpresidank 2026-04-05 21:08:02 -05:00
commit a8390532f7
310 changed files with 56430 additions and 0 deletions

View file

@ -0,0 +1,126 @@
import { describe, it, expect, vi } from "vitest";
import { textToBase64, fileToBase64 } from "../document-encoding";
describe("textToBase64", () => {
it("should encode simple ASCII text to Base64", () => {
const input = "Hello World";
const result = textToBase64(input);
expect(result).toBe("SGVsbG8gV29ybGQ=");
});
it("should encode UTF-8 text to Base64", () => {
const input = "Hello 世界";
const result = textToBase64(input);
expect(result).toBe("SGVsbG8g5LiW55WM");
});
it("should encode empty string", () => {
const result = textToBase64("");
expect(result).toBe("");
});
it("should encode special characters", () => {
const input = "!@#$%^&*()";
const result = textToBase64(input);
expect(result).toBe("IUAjJCVeJiooKQ==");
});
it("should handle newlines and tabs", () => {
const input = "Line 1\nLine 2\tTabbed";
const result = textToBase64(input);
expect(result).toBe("TGluZSAxCkxpbmUgMglUYWJiZWQ=");
});
});
describe("fileToBase64", () => {
it("should convert file to Base64 string", async () => {
const mockFile = new File(["Hello World"], "test.txt", {
type: "text/plain",
});
// Mock FileReader
const mockReader = {
result: "data:text/plain;base64,SGVsbG8gV29ybGQ=",
onloadend: null as (() => void) | null,
readAsDataURL: vi.fn(),
};
vi.spyOn(window, "FileReader").mockImplementation(
() => mockReader as Partial<FileReader>,
);
const promise = fileToBase64(mockFile);
// Simulate file read completion
mockReader.onloadend();
const result = await promise;
expect(result).toBe("SGVsbG8gV29ybGQ=");
expect(mockReader.readAsDataURL).toHaveBeenCalledWith(mockFile);
});
it("should handle different MIME types", async () => {
const mockFile = new File(["binary data"], "test.png", {
type: "image/png",
});
const mockReader = {
result:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
onloadend: null as (() => void) | null,
readAsDataURL: vi.fn(),
};
vi.spyOn(window, "FileReader").mockImplementation(
() => mockReader as Partial<FileReader>,
);
const promise = fileToBase64(mockFile);
mockReader.onloadend();
const result = await promise;
expect(result).toBe(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
);
});
it("should handle empty file", async () => {
const mockFile = new File([""], "empty.txt", { type: "text/plain" });
const mockReader = {
result: "data:text/plain;base64,",
onloadend: null as (() => void) | null,
readAsDataURL: vi.fn(),
};
vi.spyOn(window, "FileReader").mockImplementation(
() => mockReader as Partial<FileReader>,
);
const promise = fileToBase64(mockFile);
mockReader.onloadend();
const result = await promise;
expect(result).toBe("");
});
it("should remove data URL prefix correctly", async () => {
const mockFile = new File(["test"], "test.txt", { type: "text/plain" });
const mockReader = {
result: "data:text/plain;charset=utf-8;base64,dGVzdA==",
onloadend: null as (() => void) | null,
readAsDataURL: vi.fn(),
};
vi.spyOn(window, "FileReader").mockImplementation(
() => mockReader as Partial<FileReader>,
);
const promise = fileToBase64(mockFile);
mockReader.onloadend();
const result = await promise;
expect(result).toBe("dGVzdA==");
});
});

View file

@ -0,0 +1,173 @@
import { describe, it, expect, vi } from "vitest";
import { computeCosineSimilarity, sortSimilarity, type Row } from "../row";
// Mock the compute-cosine-similarity package
vi.mock("compute-cosine-similarity", () => ({
default: vi.fn(),
}));
import similarity from "compute-cosine-similarity";
describe("computeCosineSimilarity", () => {
it("should compute cosine similarity for entities with embeddings", () => {
const mockSimilarity = vi.mocked(similarity);
mockSimilarity.mockReturnValue(0.8);
const entities: Row[] = [
{
uri: "http://example.com/entity1",
label: "Entity 1",
description: "Description 1",
embeddings: [0.1, 0.2, 0.3],
target: [0.2, 0.3, 0.4],
},
{
uri: "http://example.com/entity2",
label: "Entity 2",
description: "Description 2",
embeddings: [0.4, 0.5, 0.6],
target: [0.5, 0.6, 0.7],
},
];
const result = computeCosineSimilarity()(entities);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
uri: "http://example.com/entity1",
label: "Entity 1",
description: "Description 1",
similarity: 0.8,
});
expect(result[1]).toEqual({
uri: "http://example.com/entity2",
label: "Entity 2",
description: "Description 2",
similarity: 0.8,
});
expect(mockSimilarity).toHaveBeenCalledWith(
[0.2, 0.3, 0.4],
[0.1, 0.2, 0.3],
);
expect(mockSimilarity).toHaveBeenCalledWith(
[0.5, 0.6, 0.7],
[0.4, 0.5, 0.6],
);
});
it("should handle null similarity result", () => {
const mockSimilarity = vi.mocked(similarity);
mockSimilarity.mockReturnValue(null);
const entities: Row[] = [
{
uri: "http://example.com/entity1",
label: "Entity 1",
embeddings: [0.1, 0.2, 0.3],
target: [0.2, 0.3, 0.4],
},
];
const result = computeCosineSimilarity()(entities);
expect(result[0].similarity).toBe(-1);
});
it("should handle empty entities array", () => {
const result = computeCosineSimilarity()([]);
expect(result).toEqual([]);
});
it("should preserve original entity data except embeddings and target", () => {
const mockSimilarity = vi.mocked(similarity);
mockSimilarity.mockReturnValue(0.5);
const entities: Row[] = [
{
uri: "http://example.com/entity1",
label: "Entity 1",
description: "Description 1",
embeddings: [0.1, 0.2, 0.3],
target: [0.2, 0.3, 0.4],
},
];
const result = computeCosineSimilarity()(entities);
expect(result[0]).not.toHaveProperty("embeddings");
expect(result[0]).not.toHaveProperty("target");
expect(result[0]).toHaveProperty("uri");
expect(result[0]).toHaveProperty("label");
expect(result[0]).toHaveProperty("description");
expect(result[0]).toHaveProperty("similarity");
});
});
describe("sortSimilarity", () => {
it("should sort entities by similarity in descending order", () => {
const entities: Row[] = [
{ uri: "entity1", similarity: 0.3 },
{ uri: "entity2", similarity: 0.9 },
{ uri: "entity3", similarity: 0.6 },
];
const result = sortSimilarity()(entities);
expect(result[0].similarity).toBe(0.9);
expect(result[1].similarity).toBe(0.6);
expect(result[2].similarity).toBe(0.3);
});
it("should handle entities with same similarity", () => {
const entities: Row[] = [
{ uri: "entity1", similarity: 0.5 },
{ uri: "entity2", similarity: 0.5 },
{ uri: "entity3", similarity: 0.7 },
];
const result = sortSimilarity()(entities);
expect(result[0].similarity).toBe(0.7);
expect(result[1].similarity).toBe(0.5);
expect(result[2].similarity).toBe(0.5);
});
it("should handle empty array", () => {
const result = sortSimilarity()([]);
expect(result).toEqual([]);
});
it("should handle single entity", () => {
const entities: Row[] = [{ uri: "entity1", similarity: 0.5 }];
const result = sortSimilarity()(entities);
expect(result).toEqual(entities);
});
it("should not mutate original array", () => {
const entities: Row[] = [
{ uri: "entity1", similarity: 0.3 },
{ uri: "entity2", similarity: 0.9 },
];
const originalOrder = [...entities];
sortSimilarity()(entities);
expect(entities).toEqual(originalOrder);
});
it("should handle negative similarities", () => {
const entities: Row[] = [
{ uri: "entity1", similarity: -0.5 },
{ uri: "entity2", similarity: 0.2 },
{ uri: "entity3", similarity: -0.1 },
];
const result = sortSimilarity()(entities);
expect(result[0].similarity).toBe(0.2);
expect(result[1].similarity).toBe(-0.1);
expect(result[2].similarity).toBe(-0.5);
});
});

View file

@ -0,0 +1,333 @@
/**
* Tests for SKOS parser and serializer functionality
*/
import { describe, test, expect } from "vitest";
import {
SKOSSerializer,
SKOSParser,
serializeToSKOS,
parseFromSKOS,
} from "../skos";
import { validateOntology } from "../skos-validation";
import { Ontology } from "@trustgraph/react-state";
// Sample ontology for testing
const sampleOntology: Ontology = {
metadata: {
name: "Test Ontology",
description: "A sample ontology for testing",
version: "1.0",
created: "2024-01-01T00:00:00Z",
modified: "2024-01-02T00:00:00Z",
creator: "Test User",
namespace: "http://example.org/test/",
},
scheme: {
uri: "http://example.org/test/scheme",
prefLabel: "Test Ontology",
hasTopConcept: ["concept-1", "concept-2"],
},
concepts: {
"concept-1": {
id: "concept-1",
prefLabel: "Animals",
definition: "Living organisms that feed on organic matter",
narrower: ["concept-3"],
topConcept: true,
},
"concept-2": {
id: "concept-2",
prefLabel: "Plants",
definition:
"Living organisms that typically produce their own food through photosynthesis",
narrower: ["concept-4"],
topConcept: true,
},
"concept-3": {
id: "concept-3",
prefLabel: "Mammals",
definition: "Warm-blooded vertebrate animals",
broader: "concept-1",
altLabel: ["Mammalia"],
example: ["Dog", "Cat", "Human"],
narrower: [],
related: [],
},
"concept-4": {
id: "concept-4",
prefLabel: "Trees",
definition: "Woody perennial plants with a main trunk",
broader: "concept-2",
scopeNote: "Includes both deciduous and evergreen trees",
notation: "T001",
narrower: [],
related: [],
},
},
};
describe("SKOS Serializer", () => {
test("should serialize ontology to RDF/XML", () => {
const serializer = new SKOSSerializer("http://example.org/test/");
const rdf = serializer.toRDF(sampleOntology);
// Check basic structure
expect(rdf).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(rdf).toContain("<rdf:RDF");
expect(rdf).toContain('xmlns:skos="http://www.w3.org/2004/02/skos/core#"');
// Check scheme
expect(rdf).toContain(
'<skos:ConceptScheme rdf:about="http://example.org/test/scheme">',
);
expect(rdf).toContain(
'<skos:prefLabel xml:lang="en">Test Ontology</skos:prefLabel>',
);
expect(rdf).toContain(
'<skos:hasTopConcept rdf:resource="http://example.org/test/concept-1"',
);
// Check concepts
expect(rdf).toContain(
'<skos:Concept rdf:about="http://example.org/test/concept-1">',
);
expect(rdf).toContain(
'<skos:prefLabel xml:lang="en">Animals</skos:prefLabel>',
);
expect(rdf).toContain(
'<skos:definition xml:lang="en">Living organisms that feed on organic matter</skos:definition>',
);
expect(rdf).toContain(
'<skos:narrower rdf:resource="http://example.org/test/concept-3"',
);
// Check concept with alternative labels and examples
expect(rdf).toContain(
'<skos:altLabel xml:lang="en">Mammalia</skos:altLabel>',
);
expect(rdf).toContain('<skos:example xml:lang="en">Dog</skos:example>');
expect(rdf).toContain("<skos:notation>T001</skos:notation>");
expect(rdf).toContain(
'<skos:scopeNote xml:lang="en">Includes both deciduous and evergreen trees</skos:scopeNote>',
);
});
test("should serialize ontology to Turtle", () => {
const serializer = new SKOSSerializer("http://example.org/test/");
const turtle = serializer.toTurtle(sampleOntology);
// Check prefixes
expect(turtle).toContain(
"@prefix skos: <http://www.w3.org/2004/02/skos/core#>",
);
expect(turtle).toContain("@prefix dc: <http://purl.org/dc/terms/>");
// Check scheme
expect(turtle).toContain("<http://example.org/test/scheme>");
expect(turtle).toContain("a skos:ConceptScheme");
expect(turtle).toContain('skos:prefLabel "Test Ontology"@en');
// Check concepts
expect(turtle).toContain("<http://example.org/test/concept-1>");
expect(turtle).toContain('skos:prefLabel "Animals"@en');
expect(turtle).toContain(
'skos:definition "Living organisms that feed on organic matter"@en',
);
});
test("should handle XML escaping correctly", () => {
const ontologyWithSpecialChars: Ontology = {
...sampleOntology,
concepts: {
"concept-1": {
id: "concept-1",
prefLabel: "Test & Example <tag>",
definition: "Definition with \"quotes\" and 'apostrophes'",
narrower: [],
related: [],
},
},
};
const serializer = new SKOSSerializer();
const rdf = serializer.toRDF(ontologyWithSpecialChars);
expect(rdf).toContain("Test &amp; Example &lt;tag&gt;");
expect(rdf).toContain(
"Definition with &quot;quotes&quot; and &apos;apostrophes&apos;",
);
});
});
describe("SKOS Parser", () => {
test("should parse simple RDF/XML", async () => {
const rdfXML = `<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:skos="http://www.w3.org/2004/02/skos/core#"
xmlns:dc="http://purl.org/dc/terms/">
<skos:ConceptScheme rdf:about="http://example.org/test-scheme">
<skos:prefLabel xml:lang="en">Test Scheme</skos:prefLabel>
<dc:description xml:lang="en">A test concept scheme</dc:description>
<skos:hasTopConcept rdf:resource="http://example.org/concept1" />
</skos:ConceptScheme>
<skos:Concept rdf:about="http://example.org/concept1">
<skos:inScheme rdf:resource="http://example.org/test-scheme" />
<skos:prefLabel xml:lang="en">Test Concept</skos:prefLabel>
<skos:definition xml:lang="en">A test concept definition</skos:definition>
<skos:altLabel xml:lang="en">Alternative Label</skos:altLabel>
<skos:topConceptOf rdf:resource="http://example.org/test-scheme" />
</skos:Concept>
</rdf:RDF>`;
const parser = new SKOSParser();
const ontology = await parser.parseRDF(rdfXML, "test-ontology");
// Check metadata
expect(ontology.metadata.name).toBe("Test Scheme");
expect(ontology.metadata.description).toBe("A test concept scheme");
// Check scheme
expect(ontology.scheme.uri).toBe("http://example.org/test-scheme");
expect(ontology.scheme.prefLabel).toBe("Test Scheme");
expect(ontology.scheme.hasTopConcept).toContain("concept1");
// Check concepts
expect(ontology.concepts["concept1"]).toBeDefined();
expect(ontology.concepts["concept1"].prefLabel).toBe("Test Concept");
expect(ontology.concepts["concept1"].definition).toBe(
"A test concept definition",
);
expect(ontology.concepts["concept1"].altLabel).toContain(
"Alternative Label",
);
expect(ontology.concepts["concept1"].topConcept).toBe(true);
});
test("should handle parsing errors gracefully", async () => {
const invalidXML = "<invalid>xml</content>";
const parser = new SKOSParser();
await expect(parser.parseRDF(invalidXML, "test")).rejects.toThrow(
"Invalid XML format",
);
});
});
describe("SKOS Validation", () => {
test("should validate correct ontology", () => {
const result = validateOntology(sampleOntology);
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test("should detect missing preferred labels", () => {
const invalidOntology: Ontology = {
...sampleOntology,
concepts: {
"concept-1": {
id: "concept-1",
prefLabel: "", // Empty label
narrower: [],
related: [],
},
},
};
const result = validateOntology(invalidOntology);
expect(result.isValid).toBe(false);
expect(result.errors.some((e) => e.code === "CONCEPT_NO_PREFLABEL")).toBe(
true,
);
});
test("should detect circular references", () => {
const circularOntology: Ontology = {
...sampleOntology,
concepts: {
"concept-1": {
id: "concept-1",
prefLabel: "A",
broader: "concept-2",
narrower: [],
related: [],
},
"concept-2": {
id: "concept-2",
prefLabel: "B",
broader: "concept-1", // Circular reference
narrower: [],
related: [],
},
},
};
const result = validateOntology(circularOntology);
expect(result.isValid).toBe(false);
expect(
result.errors.some((e) => e.code === "HIERARCHY_CIRCULAR_REFERENCE"),
).toBe(true);
});
test("should detect invalid concept references", () => {
const invalidRefOntology: Ontology = {
...sampleOntology,
concepts: {
"concept-1": {
id: "concept-1",
prefLabel: "Test",
broader: "non-existent-concept", // Invalid reference
narrower: [],
related: [],
},
},
};
const result = validateOntology(invalidRefOntology);
expect(result.isValid).toBe(false);
expect(
result.errors.some((e) => e.code === "CONCEPT_INVALID_BROADER"),
).toBe(true);
});
});
describe("Convenience Functions", () => {
test("should serialize to SKOS RDF by default", () => {
const result = serializeToSKOS(sampleOntology);
expect(result).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(result).toContain("<rdf:RDF");
});
test("should serialize to SKOS Turtle when specified", () => {
const result = serializeToSKOS(sampleOntology, "turtle");
expect(result).toContain("@prefix skos:");
expect(result).toContain("a skos:ConceptScheme");
});
test("should parse from SKOS RDF by default", async () => {
const rdfXML = `<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:skos="http://www.w3.org/2004/02/skos/core#">
<skos:ConceptScheme rdf:about="http://example.org/scheme">
<skos:prefLabel xml:lang="en">Test</skos:prefLabel>
</skos:ConceptScheme>
</rdf:RDF>`;
const result = await parseFromSKOS(rdfXML, "test");
expect(result.scheme.prefLabel).toBe("Test");
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { timeString } from "../time-string";
describe("timeString", () => {
beforeEach(() => {
// Mock Date to ensure consistent testing
vi.setSystemTime(new Date("2024-01-15T12:00:00Z"));
});
it("should convert Unix timestamp to localized date and time string", () => {
const timestamp = 1705320000; // 2024-01-15T12:00:00Z
const result = timeString(timestamp);
// Result should contain date and time parts
expect(result).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/);
expect(result).toMatch(/\d{1,2}:\d{2}:\d{2}/);
expect(result).toContain(" ");
});
it("should handle zero timestamp", () => {
const result = timeString(0);
expect(result).toMatch(/1970/);
});
it("should handle negative timestamp", () => {
const result = timeString(-86400); // One day before epoch
expect(result).toMatch(/1969/);
});
it("should handle large timestamp", () => {
const timestamp = 2147483647; // Max 32-bit signed integer
const result = timeString(timestamp);
expect(result).toMatch(/2038/);
});
it("should return string format", () => {
const result = timeString(1705320000);
expect(typeof result).toBe("string");
});
});

View file

@ -0,0 +1,49 @@
/**
* textToBase64 using TextEncoder
*/
export const textToBase64 = (input) => {
// Convert string to UTF-8 bytes, then to Base64
const encoder = new TextEncoder();
const bytes = encoder.encode(input);
// Convert Uint8Array to string for btoa()
const binaryString = Array.from(bytes, (byte) =>
String.fromCharCode(byte),
).join("");
return btoa(binaryString);
};
/**
* Converts a text string to Base64 encoding with proper UTF-8 handling
*
* WHY THIS LOOKS WEIRD:
* FileReader.readAsDataURL() returns a data URL like
* "data:image/png;base64,iVBORw0KGgoA..."
* but we only want the Base64 part. The regex removes the "data:" prefix
* and everything up to the comma, leaving just the Base64 data.
*
* ISSUES WITH CURRENT IMPLEMENTATION:
* - Type assertion (as string) is unsafe - reader.result could be ArrayBuffer
* - No error handling for failed file reads
* - Regex is overly complex for simple string removal
*
* @param {File} file - The file to convert to Base64
* @returns {Promise<string>} Promise that resolves to Base64 string
*/
export const fileToBase64 = (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
// FIXME: Type is 'string | ArrayBuffer'? is this safe?
// FIXME: Use Blob.arrayBuffer API?
const data = (reader.result as string)
.replace("data:", "")
.replace(/^.+,/, "");
resolve(data);
};
reader.readAsDataURL(file);
});
};

View file

374
src/utils/export-formats.ts Normal file
View file

@ -0,0 +1,374 @@
/**
* Additional export formats for ontologies
*
* This module provides conversion utilities for various
* ontology export formats beyond SKOS.
*/
import { Ontology, OntologyConcept } from "@trustgraph/react-state";
/**
* Export ontology as CSV
*/
export function exportToCSV(ontology: Ontology): string {
const concepts = Object.values(ontology.concepts);
// CSV headers
const headers = [
"ID",
"Preferred Label",
"Alternative Labels",
"Definition",
"Scope Note",
"Examples",
"Notation",
"Broader Concept ID",
"Broader Concept Label",
"Narrower Concept IDs",
"Related Concept IDs",
"Is Top Concept",
];
// Build rows
const rows = [headers.join(",")];
concepts.forEach((concept) => {
const broaderConcept = concept.broader
? ontology.concepts[concept.broader]
: null;
const row = [
`"${concept.id}"`,
`"${concept.prefLabel.replace(/"/g, '""')}"`,
`"${(concept.altLabel || []).join("; ").replace(/"/g, '""')}"`,
`"${(concept.definition || "").replace(/"/g, '""')}"`,
`"${(concept.scopeNote || "").replace(/"/g, '""')}"`,
`"${(concept.example || []).join("; ").replace(/"/g, '""')}"`,
`"${concept.notation || ""}"`,
`"${concept.broader || ""}"`,
`"${broaderConcept ? broaderConcept.prefLabel.replace(/"/g, '""') : ""}"`,
`"${(concept.narrower || []).join("; ")}"`,
`"${(concept.related || []).join("; ")}"`,
`"${concept.topConcept ? "true" : "false"}"`,
];
rows.push(row.join(","));
});
return rows.join("\n");
}
/**
* Export ontology as JSON (formatted)
*/
export function exportToJSON(ontology: Ontology): string {
return JSON.stringify(ontology, null, 2);
}
/**
* Export ontology as plain text outline
*/
export function exportToText(ontology: Ontology): string {
const lines = [];
// Header
lines.push(`Ontology: ${ontology.metadata.name}`);
lines.push(
`Description: ${ontology.metadata.description || "No description"}`,
);
lines.push(`Version: ${ontology.metadata.version}`);
lines.push(`Created: ${ontology.metadata.created}`);
lines.push(`Modified: ${ontology.metadata.modified}`);
lines.push(`Creator: ${ontology.metadata.creator}`);
lines.push("");
// Get hierarchy
const topConcepts = ontology.scheme.hasTopConcept
.map((id) => ontology.concepts[id])
.filter(Boolean);
if (topConcepts.length === 0) {
// If no top concepts defined, find root concepts
const rootConcepts = Object.values(ontology.concepts).filter(
(c) => !c.broader || !ontology.concepts[c.broader],
);
topConcepts.push(...rootConcepts);
}
// Render hierarchy
lines.push("CONCEPTS:");
lines.push("========");
lines.push("");
const renderConcept = (
concept: OntologyConcept,
indent: number = 0,
): void => {
const prefix = " ".repeat(indent);
lines.push(`${prefix}${concept.prefLabel} (${concept.id})`);
if (concept.definition) {
lines.push(`${prefix} Definition: ${concept.definition}`);
}
if (concept.altLabel && concept.altLabel.length > 0) {
lines.push(
`${prefix} Alternative labels: ${concept.altLabel.join(", ")}`,
);
}
if (concept.scopeNote) {
lines.push(`${prefix} Scope note: ${concept.scopeNote}`);
}
if (concept.example && concept.example.length > 0) {
lines.push(`${prefix} Examples: ${concept.example.join(", ")}`);
}
if (concept.notation) {
lines.push(`${prefix} Notation: ${concept.notation}`);
}
if (concept.related && concept.related.length > 0) {
const relatedLabels = concept.related
.map((id) => ontology.concepts[id]?.prefLabel || id)
.join(", ");
lines.push(`${prefix} Related: ${relatedLabels}`);
}
lines.push("");
// Render narrower concepts
if (concept.narrower && concept.narrower.length > 0) {
concept.narrower.forEach((narrowerId) => {
const narrowerConcept = ontology.concepts[narrowerId];
if (narrowerConcept) {
renderConcept(narrowerConcept, indent + 1);
}
});
}
};
topConcepts.forEach((concept) => renderConcept(concept));
return lines.join("\n");
}
/**
* Export ontology as GraphML (for network visualization)
*/
export function exportToGraphML(ontology: Ontology): string {
const concepts = Object.values(ontology.concepts);
const lines = [];
// GraphML header
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
lines.push('<graphml xmlns="http://graphml.graphdrawing.org/xmlns"');
lines.push(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"');
lines.push(
' xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns',
);
lines.push(
' http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">',
);
// Key definitions
lines.push(
' <key id="label" for="node" attr.name="label" attr.type="string"/>',
);
lines.push(
' <key id="definition" for="node" attr.name="definition" attr.type="string"/>',
);
lines.push(
' <key id="type" for="edge" attr.name="type" attr.type="string"/>',
);
lines.push("");
// Graph
lines.push(' <graph id="ontology" edgedefault="directed">');
// Nodes (concepts)
concepts.forEach((concept) => {
lines.push(` <node id="${concept.id}">`);
lines.push(
` <data key="label">${escapeXML(concept.prefLabel)}</data>`,
);
if (concept.definition) {
lines.push(
` <data key="definition">${escapeXML(concept.definition)}</data>`,
);
}
lines.push(" </node>");
});
// Edges (relationships)
concepts.forEach((concept) => {
// Broader relationships
if (concept.broader && ontology.concepts[concept.broader]) {
lines.push(
` <edge source="${concept.id}" target="${concept.broader}">`,
);
lines.push(' <data key="type">broader</data>');
lines.push(" </edge>");
}
// Related relationships
if (concept.related) {
concept.related.forEach((relatedId) => {
if (ontology.concepts[relatedId]) {
lines.push(
` <edge source="${concept.id}" target="${relatedId}">`,
);
lines.push(' <data key="type">related</data>');
lines.push(" </edge>");
}
});
}
});
lines.push(" </graph>");
lines.push("</graphml>");
return lines.join("\n");
}
/**
* Export ontology as DOT format (for Graphviz)
*/
export function exportToDOT(ontology: Ontology): string {
const concepts = Object.values(ontology.concepts);
const lines = [];
lines.push("digraph ontology {");
lines.push(" rankdir=TB;");
lines.push(
' node [shape=box, style="rounded,filled", fillcolor=lightblue];',
);
lines.push(" edge [color=darkgreen];");
lines.push("");
// Nodes
concepts.forEach((concept) => {
const label = concept.prefLabel.replace(/"/g, '\\"');
const shape = concept.topConcept ? "ellipse" : "box";
const fillColor = concept.topConcept ? "gold" : "lightblue";
lines.push(
` "${concept.id}" [label="${label}", shape=${shape}, fillcolor=${fillColor}];`,
);
});
lines.push("");
// Edges
concepts.forEach((concept) => {
// Broader relationships (hierarchical)
if (concept.broader && ontology.concepts[concept.broader]) {
lines.push(
` "${concept.broader}" -> "${concept.id}" [label="narrower", color=blue];`,
);
}
// Related relationships
if (concept.related) {
concept.related.forEach((relatedId) => {
if (ontology.concepts[relatedId] && concept.id < relatedId) {
// Only draw one direction to avoid duplicate edges
lines.push(
` "${concept.id}" -> "${relatedId}" [label="related", color=red, dir=both];`,
);
}
});
}
});
lines.push("}");
return lines.join("\n");
}
/**
* Get export format information
*/
export const EXPORT_FORMATS = {
"skos-rdf": {
name: "SKOS RDF/XML",
extension: "rdf",
mimeType: "application/rdf+xml",
description: "Standard SKOS format in RDF/XML syntax",
},
"skos-turtle": {
name: "SKOS Turtle",
extension: "ttl",
mimeType: "text/turtle",
description: "Standard SKOS format in Turtle syntax",
},
json: {
name: "JSON",
extension: "json",
mimeType: "application/json",
description: "Internal JSON format (for backup/restore)",
},
csv: {
name: "CSV",
extension: "csv",
mimeType: "text/csv",
description: "Comma-separated values (for spreadsheet import)",
},
text: {
name: "Text Outline",
extension: "txt",
mimeType: "text/plain",
description: "Human-readable text outline format",
},
graphml: {
name: "GraphML",
extension: "graphml",
mimeType: "application/xml",
description: "Network graph format (for yEd, Gephi, etc.)",
},
dot: {
name: "DOT/Graphviz",
extension: "dot",
mimeType: "text/plain",
description: "Graphviz format for network visualization",
},
} as const;
export type ExportFormat = keyof typeof EXPORT_FORMATS;
/**
* Export ontology in specified format
*/
export function exportOntology(
ontology: Ontology,
format: ExportFormat,
): string {
switch (format) {
case "csv":
return exportToCSV(ontology);
case "json":
return exportToJSON(ontology);
case "text":
return exportToText(ontology);
case "graphml":
return exportToGraphML(ontology);
case "dot":
return exportToDOT(ontology);
default:
throw new Error(`Unsupported export format: ${format}`);
}
}
// Helper function for XML escaping
function escapeXML(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

View file

@ -0,0 +1,167 @@
// Functionality here helps construct subgraphs for react-force-graph
// visualisation
import { Triple, BaseApi } from "@trustgraph/client";
import {
query,
labelS,
labelP,
labelO,
filterInternals,
} from "./knowledge-graph";
interface Node {
id: string;
label: string;
group: number;
}
interface Link {
id: string;
source: string;
target: string;
label: string;
value: number;
}
export interface Subgraph {
nodes: Node[];
links: Link[];
}
export const createSubgraph = (): Subgraph => {
return {
nodes: [],
links: [],
};
};
export const updateSubgraphTriples = (sg: Subgraph, triples: Triple[]) => {
const groupId = 1;
const nodeIds = new Set<string>(sg.nodes.map((n) => n.id));
const linkIds = new Set<string>(sg.links.map((n) => n.id));
for (const t of triples) {
// Skip triples where the object is a literal (property edges)
// These are now shown in the node details drawer instead
if (!t.o.e) {
continue;
}
// Source has a URI, that can be its unique ID
const sourceId = t.s.v;
// Target is always an entity now (we filtered out literals above)
const targetId = t.o.v;
// Links have an ID so that this edge is unique
const linkId = t.s.v + "@@" + t.p.v + "@@" + t.o.v;
if (!nodeIds.has(sourceId)) {
const n: Node = {
id: sourceId,
label: t.s.label ? t.s.label : "unknown",
group: groupId,
};
nodeIds.add(sourceId);
sg = {
...sg,
nodes: [...sg.nodes, n],
};
}
if (!nodeIds.has(targetId)) {
const n: Node = {
id: targetId,
label: t.o.label ? t.o.label : "unknown",
group: groupId,
};
nodeIds.add(targetId);
sg = {
...sg,
nodes: [...sg.nodes, n],
};
}
if (!linkIds.has(linkId)) {
const l: Link = {
source: sourceId,
target: targetId,
id: linkId,
label: t.p.label ? t.p.label : "unknown",
value: 1,
};
linkIds.add(linkId);
sg = {
...sg,
links: [...sg.links, l],
};
}
}
return sg;
};
export const updateSubgraph = (
socket: BaseApi,
flowId: string,
uri: string,
sg: Subgraph,
add: (s: string) => void,
remove: (s: string) => void,
collection?: string,
) => {
const api = socket.flow(flowId);
return query(api, uri, add, remove, undefined, collection)
.then((d) => labelS(api, d, add, remove, collection))
.then((d) => labelP(api, d, add, remove, collection))
.then((d) => labelO(api, d, add, remove, collection))
.then((d) => filterInternals(d))
.then((d) => updateSubgraphTriples(sg, d));
};
export const updateSubgraphByRelationship = (
socket: BaseApi,
flowId: string,
selectedNodeId: string,
relationshipUri: string,
direction: "incoming" | "outgoing",
sg: Subgraph,
add: (s: string) => void,
remove: (s: string) => void,
collection?: string,
) => {
const api = socket.flow(flowId);
const activityName = `Following ${direction} relationship: ${relationshipUri}`;
add(activityName);
// Build the query based on direction
const queryPromise =
direction === "outgoing"
? api.triplesQuery(
{ v: selectedNodeId, e: true }, // s = selectedNode
{ v: relationshipUri, e: true }, // p = relationship
undefined, // o = ??? (what we want to find)
20, // Limit results
collection,
)
: api.triplesQuery(
undefined, // s = ??? (what we want to find)
{ v: relationshipUri, e: true }, // p = relationship
{ v: selectedNodeId, e: true }, // o = selectedNode
20, // Limit results
collection,
);
return queryPromise
.then((triples) => {
remove(activityName);
return triples;
})
.then((d) => labelS(api, d, add, remove, collection))
.then((d) => labelP(api, d, add, remove, collection))
.then((d) => labelO(api, d, add, remove, collection))
.then((d) => filterInternals(d))
.then((d) => updateSubgraphTriples(sg, d));
};

View file

@ -0,0 +1,333 @@
import { BaseApi, Triple } from "@trustgraph/client";
export const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
export const SKOS_DEFINITION =
"http://www.w3.org/2004/02/skos/core#definition";
export const SCHEMAORG_SUBJECT_OF = "https://schema.org/subjectOf";
export const SCHEMAORG_DESCRIPTION = "https://schema.org/description";
// Some pre-defined labels, don't need to be fetched from the graph
const predefined: { [k: string]: string } = {
[RDFS_LABEL]: "label",
[SKOS_DEFINITION]: "definition",
[SCHEMAORG_SUBJECT_OF]: "subject of",
[SCHEMAORG_DESCRIPTION]: "description",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#type": "has type",
"https://schema.org/publication": "publication",
"https://schema.org/url": "url",
"https://schema.org/PublicationEvent": "publication event",
"https://schema.org/publishedBy": "published by",
"https://schema.org/DigitalDocument": "digital document",
"https://schema.org/startDate": "start date",
"https://schema.org/endDate": "end date",
"https://schema.org/name": "name",
"https://schema.org/copyrightNotice": "copyright notice",
"https://schema.org/copyrightHolder": "copyright holder",
"https://schema.org/copyrightYear": "copyright year",
"https://schema.org/keywords": "keywords",
};
// Default triple limit on queries
export const LIMIT = 30;
// Query triples which match URI on 's'
export const queryS = (
socket: BaseApi,
uri: string,
add: (s: string) => void,
remove: (s: string) => void,
limit?: number,
collection?: string,
) => {
const act = "Query S: " + uri;
add(act);
return socket
.triplesQuery(
{ v: uri, e: true },
undefined,
undefined,
limit ? limit : LIMIT,
collection,
)
.then((x) => {
remove(act);
return x;
})
.catch((err) => {
remove(act);
throw err;
});
};
// Query triples which match URI on 'p'
export const queryP = (
socket: BaseApi,
uri: string,
add: (s: string) => void,
remove: (s: string) => void,
limit?: number,
collection?: string,
) => {
const act = "Query P: " + uri;
add(act);
return socket
.triplesQuery(
undefined,
{ v: uri, e: true },
undefined,
limit ? limit : LIMIT,
collection,
)
.then((x) => {
remove(act);
return x;
})
.catch((err) => {
remove(act);
throw err;
});
};
// Query triples which match URI on 'o'
export const queryO = (
socket: BaseApi,
uri: string,
add: (s: string) => void,
remove: (s: string) => void,
limit?: number,
collection?: string,
) => {
const act = "Query O: " + uri;
add(act);
return socket
.triplesQuery(
undefined,
undefined,
{ v: uri, e: true },
limit ? limit : LIMIT,
collection,
)
.then((x) => {
remove(act);
return x;
})
.catch((err) => {
remove(act);
throw err;
});
};
// Query triples which match URI on 's', 'p' or 'o'.
export const query = (
socket: BaseApi,
uri: string,
add: (s: string) => void,
remove: (s: string) => void,
limit?: number,
collection?: string,
) => {
const act = "Query: " + uri;
add(act);
return Promise.all([
queryS(socket, uri, add, remove, limit, collection),
queryP(socket, uri, add, remove, limit, collection),
queryO(socket, uri, add, remove, limit, collection),
])
.then((resp) => {
return resp[0].concat(resp[1]).concat(resp[2]);
})
.then((x) => {
remove(act);
return x;
})
.catch((err) => {
remove(act);
throw err;
});
};
// Convert a URI to its label by querying the graph store, returns a
// promise
export const queryLabel = (
socket: BaseApi,
uri: string,
add: (s: string) => void,
remove: (s: string) => void,
collection?: string,
): Promise<string> => {
const act = "Label " + uri;
// If the URI is in the pre-defined list, just return that
if (uri in predefined) {
return new Promise((s) => s(predefined[uri]));
}
add(act);
// Search tthe graph for the URI->label relationship
return socket
.triplesQuery(
{ v: uri, e: true },
{ v: RDFS_LABEL, e: true },
undefined,
1,
collection,
)
.then((triples: Triple[]) => {
// If got a result, return the label, otherwise the URI
// can be its own label
if (triples.length > 0) return triples[0].o.v;
else return uri;
})
.then((x) => {
remove(act);
return x;
})
.catch((err) => {
remove(act);
throw err;
});
};
// Add 'label' elements to 's' elements in a list of triples.
// Returns a promise
export const labelS = (
socket: BaseApi,
triples: Triple[],
add: (s: string) => void,
remove: (s: string) => void,
collection?: string,
) => {
return Promise.all(
triples.map((t) => {
return queryLabel(socket, t.s.v, add, remove, collection).then(
(label: string) => {
return {
...t,
s: {
...t.s,
label: label,
},
};
},
);
}),
);
};
// Add 'label' elements to 'p' elements in a list of triples.
// Returns a promise
export const labelP = (
socket: BaseApi,
triples: Triple[],
add: (s: string) => void,
remove: (s: string) => void,
collection?: string,
) => {
return Promise.all(
triples.map((t) => {
return queryLabel(socket, t.p.v, add, remove, collection).then(
(label: string) => {
return {
...t,
p: {
...t.p,
label: label,
},
};
},
);
}),
);
};
// Add 'label' elements to 'o' elements in a list of triples.
// Returns a promise
export const labelO = (
socket: BaseApi,
triples: Triple[],
add: (s: string) => void,
remove: (s: string) => void,
collection?: string,
) => {
return Promise.all(
triples.map((t) => {
// If the 'o' element is a entity, do a label lookup, else
// just use the literal value for its label
if (t.o.e)
return queryLabel(socket, t.o.v, add, remove, collection).then(
(label: string) => {
return {
...t,
o: {
...t.o,
label: label,
},
};
},
);
else
return new Promise((resolve) => {
resolve({
...t,
o: {
...t.o,
label: t.o.v,
},
});
});
}),
);
};
// Triple filter
export const filter = (triples: Triple[], fn: (t: Triple) => boolean) =>
triples.filter((t) => fn(t));
// Filter out 'structural' edges nobody needs to see
export const filterInternals = (triples: Triple[]) =>
triples.filter((t) => {
if (t.p.e && t.p.v == RDFS_LABEL) return false;
return true;
});
// Generic triple fetcher, fetches triples related to a URI, adds labels
// and provides over-arching uri/label props for the input URI
export const getTriples = (
socket: BaseApi,
flowId: string,
uri: string,
add: (s: string) => void,
remove: (s: string) => void,
limit?: number,
collection?: string,
) => {
// FIXME: Cache more
// FIXME: Too many queries
const api = socket.flow(flowId);
return query(api, uri, add, remove, limit, collection)
.then((d) => labelS(api, d, add, remove, collection))
.then((d) => labelP(api, d, add, remove, collection))
.then((d) => labelO(api, d, add, remove, collection))
.then((d) => filterInternals(d))
.then((d) => {
return queryLabel(api, uri, add, remove, collection).then(
(label: string) => {
return {
triples: d,
uri: uri,
label: label,
};
},
);
});
};

480
src/utils/ontology-qa.ts Normal file
View file

@ -0,0 +1,480 @@
/**
* Ontology Quality Assurance Utilities
*
* This module provides tools for automatically fixing common ontology issues
* and enhancing ontology quality.
*/
import { Ontology } from "@trustgraph/react-state";
import { validateOntology, ValidationError } from "./skos-validation";
export interface QAFix {
type: "auto" | "manual";
description: string;
conceptId?: string;
field?: string;
oldValue?: unknown;
newValue?: unknown;
}
export interface QAResult {
ontology: Ontology;
fixes: QAFix[];
remainingIssues: ValidationError[];
}
export class OntologyQA {
/**
* Auto-fix common ontology issues
*/
static autoFix(ontology: Ontology): QAResult {
let updatedOntology = { ...ontology };
const fixes: QAFix[] = [];
// Fix missing scheme URI
if (!updatedOntology.scheme.uri && updatedOntology.metadata.namespace) {
const newUri = `${updatedOntology.metadata.namespace}${updatedOntology.metadata.name.replace(/\s+/g, "-").toLowerCase()}`;
updatedOntology = {
...updatedOntology,
scheme: {
...updatedOntology.scheme,
uri: newUri,
},
};
fixes.push({
type: "auto",
description: "Generated missing scheme URI",
field: "scheme.uri",
newValue: newUri,
});
}
// Fix missing scheme prefLabel
if (!updatedOntology.scheme.prefLabel && updatedOntology.metadata.name) {
updatedOntology = {
...updatedOntology,
scheme: {
...updatedOntology.scheme,
prefLabel: updatedOntology.metadata.name,
},
};
fixes.push({
type: "auto",
description: "Set scheme prefLabel from ontology name",
field: "scheme.prefLabel",
newValue: updatedOntology.metadata.name,
});
}
// Fix relationship inconsistencies
const relationshipFixes =
this.fixRelationshipInconsistencies(updatedOntology);
updatedOntology = relationshipFixes.ontology;
fixes.push(...relationshipFixes.fixes);
// Remove duplicate relationships
const duplicateFixes = this.removeDuplicateRelationships(updatedOntology);
updatedOntology = duplicateFixes.ontology;
fixes.push(...duplicateFixes.fixes);
// Fix orphaned concepts by connecting them to existing hierarchy
const orphanFixes = this.fixOrphanedConcepts(updatedOntology);
updatedOntology = orphanFixes.ontology;
fixes.push(...orphanFixes.fixes);
// Clean up empty relationships
const cleanupFixes = this.cleanupEmptyRelationships(updatedOntology);
updatedOntology = cleanupFixes.ontology;
fixes.push(...cleanupFixes.fixes);
// Validate the fixed ontology
const validation = validateOntology(updatedOntology);
return {
ontology: updatedOntology,
fixes,
remainingIssues: [...validation.errors, ...validation.warnings],
};
}
/**
* Fix relationship inconsistencies (ensure broader/narrower relationships are reciprocated)
*/
private static fixRelationshipInconsistencies(ontology: Ontology): {
ontology: Ontology;
fixes: QAFix[];
} {
const updatedConcepts = { ...ontology.concepts };
const fixes: QAFix[] = [];
Object.values(updatedConcepts).forEach((concept) => {
// Ensure broader relationships are reciprocated
if (concept.broader && updatedConcepts[concept.broader]) {
const broaderConcept = updatedConcepts[concept.broader];
if (!broaderConcept.narrower?.includes(concept.id)) {
updatedConcepts[concept.broader] = {
...broaderConcept,
narrower: [...(broaderConcept.narrower || []), concept.id],
};
fixes.push({
type: "auto",
description: `Added ${concept.prefLabel} as narrower concept to ${broaderConcept.prefLabel}`,
conceptId: concept.broader,
field: "narrower",
});
}
}
// Ensure narrower relationships are reciprocated
if (concept.narrower) {
concept.narrower.forEach((narrowerId) => {
const narrowerConcept = updatedConcepts[narrowerId];
if (narrowerConcept && narrowerConcept.broader !== concept.id) {
updatedConcepts[narrowerId] = {
...narrowerConcept,
broader: concept.id,
};
fixes.push({
type: "auto",
description: `Set ${concept.prefLabel} as broader concept for ${narrowerConcept.prefLabel}`,
conceptId: narrowerId,
field: "broader",
});
}
});
}
// Ensure related relationships are symmetric
if (concept.related) {
concept.related.forEach((relatedId) => {
const relatedConcept = updatedConcepts[relatedId];
if (
relatedConcept &&
!relatedConcept.related?.includes(concept.id)
) {
updatedConcepts[relatedId] = {
...relatedConcept,
related: [...(relatedConcept.related || []), concept.id],
};
fixes.push({
type: "auto",
description: `Made ${concept.prefLabel} and ${relatedConcept.prefLabel} mutually related`,
conceptId: relatedId,
field: "related",
});
}
});
}
});
return {
ontology: {
...ontology,
concepts: updatedConcepts,
},
fixes,
};
}
/**
* Remove duplicate relationships within concepts
*/
private static removeDuplicateRelationships(ontology: Ontology): {
ontology: Ontology;
fixes: QAFix[];
} {
const updatedConcepts = { ...ontology.concepts };
const fixes: QAFix[] = [];
Object.values(updatedConcepts).forEach((concept) => {
let hasChanges = false;
// Remove duplicate narrower relationships
if (concept.narrower && concept.narrower.length > 0) {
const uniqueNarrower = [...new Set(concept.narrower)];
if (uniqueNarrower.length !== concept.narrower.length) {
updatedConcepts[concept.id] = {
...concept,
narrower: uniqueNarrower,
};
hasChanges = true;
}
}
// Remove duplicate related relationships
if (concept.related && concept.related.length > 0) {
const uniqueRelated = [...new Set(concept.related)];
if (uniqueRelated.length !== concept.related.length) {
updatedConcepts[concept.id] = {
...updatedConcepts[concept.id],
related: uniqueRelated,
};
hasChanges = true;
}
}
// Remove duplicate alternative labels
if (concept.altLabel && concept.altLabel.length > 0) {
const uniqueAltLabels = [...new Set(concept.altLabel)];
if (uniqueAltLabels.length !== concept.altLabel.length) {
updatedConcepts[concept.id] = {
...updatedConcepts[concept.id],
altLabel: uniqueAltLabels,
};
hasChanges = true;
}
}
if (hasChanges) {
fixes.push({
type: "auto",
description: `Removed duplicate relationships and labels from ${concept.prefLabel}`,
conceptId: concept.id,
});
}
});
return {
ontology: {
...ontology,
concepts: updatedConcepts,
},
fixes,
};
}
/**
* Fix orphaned concepts by suggesting connections or promoting to top concepts
*/
private static fixOrphanedConcepts(ontology: Ontology): {
ontology: Ontology;
fixes: QAFix[];
} {
const updatedOntology = { ...ontology };
const fixes: QAFix[] = [];
const concepts = Object.values(ontology.concepts);
const topConceptIds = new Set(ontology.scheme.hasTopConcept);
// Find orphaned concepts (no broader and not a top concept)
const orphanedConcepts = concepts.filter(
(concept) => !concept.broader && !topConceptIds.has(concept.id),
);
if (orphanedConcepts.length > 0) {
// If there are few orphaned concepts and the ontology is small, promote them to top concepts
if (orphanedConcepts.length <= 3 && concepts.length <= 20) {
updatedOntology.scheme = {
...updatedOntology.scheme,
hasTopConcept: [
...updatedOntology.scheme.hasTopConcept,
...orphanedConcepts.map((c) => c.id),
],
};
orphanedConcepts.forEach((concept) => {
fixes.push({
type: "auto",
description: `Promoted "${concept.prefLabel}" to top concept`,
conceptId: concept.id,
});
});
} else {
// For larger ontologies, suggest manual review
orphanedConcepts.forEach((concept) => {
fixes.push({
type: "manual",
description: `Orphaned concept "${concept.prefLabel}" needs to be connected to the hierarchy or marked as a top concept`,
conceptId: concept.id,
});
});
}
}
return {
ontology: updatedOntology,
fixes,
};
}
/**
* Clean up empty or invalid relationships
*/
private static cleanupEmptyRelationships(ontology: Ontology): {
ontology: Ontology;
fixes: QAFix[];
} {
const updatedConcepts = { ...ontology.concepts };
const fixes: QAFix[] = [];
Object.values(updatedConcepts).forEach((concept) => {
let hasChanges = false;
// Remove invalid narrower relationships (pointing to non-existent concepts)
if (concept.narrower && concept.narrower.length > 0) {
const validNarrower = concept.narrower.filter(
(id) => updatedConcepts[id],
);
if (validNarrower.length !== concept.narrower.length) {
updatedConcepts[concept.id] = {
...concept,
narrower: validNarrower.length > 0 ? validNarrower : undefined,
};
hasChanges = true;
}
}
// Remove invalid related relationships
if (concept.related && concept.related.length > 0) {
const validRelated = concept.related.filter(
(id) => updatedConcepts[id],
);
if (validRelated.length !== concept.related.length) {
updatedConcepts[concept.id] = {
...updatedConcepts[concept.id],
related: validRelated.length > 0 ? validRelated : undefined,
};
hasChanges = true;
}
}
// Remove invalid broader relationship
if (concept.broader && !updatedConcepts[concept.broader]) {
updatedConcepts[concept.id] = {
...updatedConcepts[concept.id],
broader: undefined,
};
hasChanges = true;
}
if (hasChanges) {
fixes.push({
type: "auto",
description: `Cleaned up invalid relationships for ${concept.prefLabel}`,
conceptId: concept.id,
});
}
});
// Clean up invalid top concepts
const validTopConcepts = ontology.scheme.hasTopConcept.filter(
(id) => updatedConcepts[id],
);
if (validTopConcepts.length !== ontology.scheme.hasTopConcept.length) {
const updatedScheme = {
...ontology.scheme,
hasTopConcept: validTopConcepts,
};
fixes.push({
type: "auto",
description: "Removed invalid top concept references",
field: "scheme.hasTopConcept",
});
return {
ontology: {
...ontology,
concepts: updatedConcepts,
scheme: updatedScheme,
},
fixes,
};
}
return {
ontology: {
...ontology,
concepts: updatedConcepts,
},
fixes,
};
}
/**
* Generate quality improvement suggestions
*/
static generateSuggestions(ontology: Ontology): QAFix[] {
const suggestions: QAFix[] = [];
const concepts = Object.values(ontology.concepts);
// Suggest adding definitions for concepts without them
concepts
.filter((c) => !c.definition)
.forEach((concept) => {
suggestions.push({
type: "manual",
description: `Add definition for "${concept.prefLabel}" to improve clarity`,
conceptId: concept.id,
field: "definition",
});
});
// Suggest adding alternative labels for better searchability
concepts
.filter((c) => !c.altLabel || c.altLabel.length === 0)
.forEach((concept) => {
suggestions.push({
type: "manual",
description: `Consider adding alternative labels for "${concept.prefLabel}" to improve searchability`,
conceptId: concept.id,
field: "altLabel",
});
});
// Suggest notation for concepts in larger ontologies
if (concepts.length > 20) {
concepts
.filter((c) => !c.notation)
.forEach((concept) => {
suggestions.push({
type: "manual",
description: `Consider adding notation/code for "${concept.prefLabel}" to aid in referencing`,
conceptId: concept.id,
field: "notation",
});
});
}
// Suggest improving hierarchy depth if too shallow
const maxDepth = this.calculateMaxDepth(ontology);
if (maxDepth < 3 && concepts.length > 10) {
suggestions.push({
type: "manual",
description: `Ontology hierarchy is shallow (max depth: ${maxDepth}). Consider adding more specific subconcepts.`,
});
}
return suggestions;
}
/**
* Calculate maximum hierarchy depth
*/
private static calculateMaxDepth(ontology: Ontology): number {
const concepts = ontology.concepts;
const topConcepts = ontology.scheme.hasTopConcept;
if (topConcepts.length === 0) return 0;
let maxDepth = 1;
const calculateDepth = (conceptId: string, currentDepth: number): void => {
const concept = concepts[conceptId];
if (!concept) return;
maxDepth = Math.max(maxDepth, currentDepth);
if (concept.narrower) {
concept.narrower.forEach((narrowerId) => {
calculateDepth(narrowerId, currentDepth + 1);
});
}
};
topConcepts.forEach((topConceptId) => {
calculateDepth(topConceptId, 1);
});
return maxDepth;
}
}

192
src/utils/row.ts Normal file
View file

@ -0,0 +1,192 @@
import similarity from "compute-cosine-similarity";
import { Value, BaseApi } from "@trustgraph/client";
import { RDFS_LABEL, SKOS_DEFINITION } from "./knowledge-graph";
export interface Row {
uri: string;
label?: string;
description?: string;
embeddings?: number[];
target?: number[];
similarity?: number;
}
// Take the embeddings, and lookup entities using graph
// embeddings, add embedding to each entity row, just an easy
// place to put it
export const getGraphEmbeddings = (
socket: BaseApi,
add: (s: string) => void,
remove: (s: string) => void,
limit?: number,
collection?: string,
) => {
// Take the embeddings, and lookup entities using graph
// embeddings, add embedding to each entity row, just an easy
// place to put it
return (vecs: number[][]): Promise<Row[]> => {
const act = "Graph embedding search";
add(act);
return socket
.graphEmbeddingsQuery(vecs, limit ? limit : 10, collection)
.then((ents: Value[]): Row[] => {
remove(act);
return ents.map((ent) => {
return { uri: ent.v, target: vecs[0] };
});
})
.catch((err) => {
remove(act);
throw err;
});
};
};
// For entities, lookup labels
export const addRowLabels =
(
socket: BaseApi,
add: (s: string) => void,
remove: (s: string) => void,
collection?: string,
) =>
(entities: Row[]): Promise<Row[]> => {
return Promise.all<Row>(
entities.map((ent: Row) => {
const act = "Label " + ent.uri;
add(act);
return socket
.triplesQuery(
{ v: ent.uri, e: true },
{ v: RDFS_LABEL, e: true },
undefined,
1,
collection,
)
.then((t): Row => {
if (t.length < 1) {
remove(act);
return {
uri: ent.uri,
label: "",
target: ent.target,
};
} else {
remove(act);
return {
uri: ent.uri,
label: t[0].o.v,
target: ent.target,
};
}
})
.catch((err) => {
remove(act);
throw err;
});
}),
);
};
// For entities, lookup definitions
export const addRowDefinitions =
(
socket: BaseApi,
add: (s: string) => void,
remove: (s: string) => void,
collection?: string,
) =>
// For entities, lookup labels
(entities: Row[]) => {
return Promise.all<Row>(
entities.map((ent) => {
const act = "Description " + ent.uri;
add(act);
return socket
.triplesQuery(
{ v: ent.uri, e: true },
{ v: SKOS_DEFINITION, e: true },
undefined,
1,
collection,
)
.then((t) => {
if (t.length < 1) {
remove(act);
return { ...ent, description: "" };
} else {
remove(act);
return {
...ent,
description: t[0].o.v,
};
}
})
.catch((err) => {
remove(act);
throw err;
});
}),
);
};
// Compute an embedding for each entity based on its definition or label
export const addRowEmbeddings =
(socket: BaseApi, add: (s: string) => void, remove: (s: string) => void) =>
(entities: Row[]) => {
return Promise.all<Row>(
entities.map((ent) => {
let text: string = "";
if (ent.description && ent.description != "") text = ent.description;
else text = ent.label!;
const act = "Embeddings " + text.substring(0, 20);
add(act);
return socket
.embeddings(text)
.then((x) => {
if (x && x.length > 0) {
remove(act);
return {
...ent,
embeddings: x[0],
};
} else {
remove(act);
return {
...ent,
embeddings: [],
};
}
})
.catch((err) => {
remove(act);
throw err;
});
}),
);
};
// Rest of the procecess is not async, so not adding progress
export const computeCosineSimilarity =
() =>
(entities: Row[]): Row[] =>
entities.map((ent) => {
const sim = similarity(ent.target!, ent.embeddings!);
return {
uri: ent.uri,
label: ent.label,
description: ent.description,
similarity: sim ? sim : -1,
};
});
export const sortSimilarity = () => (entities: Row[]) => {
const arr = Array.from(entities);
arr.sort((a, b) => b.similarity! - a.similarity!);
return arr;
};

View file

@ -0,0 +1,75 @@
import { Schema, SchemaTableRow } from "../model/schemas-table";
export const validateSchema = (
schema: Schema,
existingSchemas: SchemaTableRow[],
newSchemaId?: string,
): string[] => {
const errors: string[] = [];
// Check if schema ID is provided for new schemas
if (newSchemaId !== undefined) {
if (!newSchemaId.trim()) {
errors.push("Schema ID is required");
} else if (existingSchemas.some(([id]) => id === newSchemaId)) {
errors.push(`Schema with ID "${newSchemaId}" already exists`);
}
}
// Check if name is provided
if (!schema.name.trim()) {
errors.push("Schema name is required");
}
// Check if description is provided
if (!schema.description.trim()) {
errors.push("Schema description is required");
}
// Check if at least one field exists
if (schema.fields.length === 0) {
errors.push("At least one field is required");
}
// Check for duplicate field names
const fieldNames = schema.fields.map((f) => f.name);
const duplicateFields = fieldNames.filter(
(name, index) => fieldNames.indexOf(name) !== index,
);
if (duplicateFields.length > 0) {
errors.push(`Duplicate field names: ${duplicateFields.join(", ")}`);
}
// Check if all fields have names
schema.fields.forEach((field, index) => {
if (!field.name.trim()) {
errors.push(`Field ${index + 1} must have a name`);
}
});
// Check if at least one primary key field exists
const hasPrimaryKey = schema.fields.some((f) => f.primary_key);
if (!hasPrimaryKey) {
errors.push("At least one primary key field is required");
}
// Check enum fields have values
schema.fields.forEach((field) => {
if (field.type === "enum") {
if (!field.enum || field.enum.length === 0) {
errors.push(`Enum field "${field.name}" must have at least one value`);
}
}
});
// Check that indexed fields exist
if (schema.indexes) {
schema.indexes.forEach((indexField) => {
if (!fieldNames.includes(indexField)) {
errors.push(`Indexed field "${indexField}" does not exist`);
}
});
}
return errors;
};

View file

@ -0,0 +1,458 @@
/**
* SKOS Validation Utilities
*
* This module provides validation functions to ensure ontologies
* comply with SKOS standards and best practices.
*/
import { Ontology } from "@trustgraph/react-state";
export interface ValidationError {
type: "error" | "warning" | "info";
code: string;
message: string;
conceptId?: string;
details?: Record<string, unknown>;
}
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationError[];
info: ValidationError[];
}
export class SKOSValidator {
/**
* Comprehensive SKOS validation
*/
validate(ontology: Ontology): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
const info: ValidationError[] = [];
// Validate scheme
this.validateScheme(ontology, errors, warnings, info);
// Validate concepts
this.validateConcepts(ontology, errors, warnings, info);
// Validate relationships
this.validateRelationships(ontology, errors, warnings, info);
// Validate hierarchy
this.validateHierarchy(ontology, errors, warnings, info);
return {
isValid: errors.length === 0,
errors,
warnings,
info,
};
}
private validateScheme(
ontology: Ontology,
errors: ValidationError[],
warnings: ValidationError[],
) {
const { scheme, metadata } = ontology;
// Required scheme properties
if (!scheme.uri) {
errors.push({
type: "error",
code: "SCHEME_NO_URI",
message: "Concept scheme must have a URI",
});
}
if (!scheme.prefLabel) {
errors.push({
type: "error",
code: "SCHEME_NO_PREFLABEL",
message: "Concept scheme must have a preferred label",
});
}
// URI format validation
if (scheme.uri && !this.isValidURI(scheme.uri)) {
warnings.push({
type: "warning",
code: "SCHEME_INVALID_URI",
message: `Scheme URI "${scheme.uri}" may not be a valid URI format`,
});
}
// Top concepts validation
if (scheme.hasTopConcept.length === 0) {
warnings.push({
type: "warning",
code: "SCHEME_NO_TOP_CONCEPTS",
message: "Concept scheme has no top concepts defined",
});
}
// Validate that all hasTopConcept references exist
scheme.hasTopConcept.forEach((conceptId) => {
if (!ontology.concepts[conceptId]) {
errors.push({
type: "error",
code: "SCHEME_INVALID_TOP_CONCEPT",
message: `Scheme references non-existent top concept: ${conceptId}`,
conceptId,
});
}
});
// Metadata validation
if (!metadata.name) {
warnings.push({
type: "warning",
code: "METADATA_NO_NAME",
message: "Ontology should have a name",
});
}
}
private validateConcepts(
ontology: Ontology,
errors: ValidationError[],
warnings: ValidationError[],
info: ValidationError[],
) {
const concepts = ontology.concepts;
Object.values(concepts).forEach((concept) => {
// Required properties
if (!concept.prefLabel || concept.prefLabel.trim().length === 0) {
errors.push({
type: "error",
code: "CONCEPT_NO_PREFLABEL",
message: `Concept ${concept.id} must have a preferred label`,
conceptId: concept.id,
});
}
// Recommended properties
if (!concept.definition) {
warnings.push({
type: "warning",
code: "CONCEPT_NO_DEFINITION",
message: `Concept "${concept.prefLabel}" (${concept.id}) should have a definition`,
conceptId: concept.id,
});
}
// Alternative labels should not duplicate preferred label
if (concept.altLabel) {
concept.altLabel.forEach((altLabel) => {
if (altLabel === concept.prefLabel) {
warnings.push({
type: "warning",
code: "CONCEPT_DUPLICATE_LABEL",
message: `Concept "${concept.prefLabel}" has duplicate label in altLabel`,
conceptId: concept.id,
});
}
});
// Check for duplicate alternative labels
const uniqueAltLabels = new Set(concept.altLabel);
if (uniqueAltLabels.size !== concept.altLabel.length) {
warnings.push({
type: "warning",
code: "CONCEPT_DUPLICATE_ALTLABEL",
message: `Concept "${concept.prefLabel}" has duplicate alternative labels`,
conceptId: concept.id,
});
}
}
// Notation format (if present)
if (concept.notation && !/^[A-Za-z0-9._-]+$/.test(concept.notation)) {
info.push({
type: "info",
code: "CONCEPT_NOTATION_FORMAT",
message: `Concept "${concept.prefLabel}" notation "${concept.notation}" uses non-standard characters`,
conceptId: concept.id,
});
}
});
}
private validateRelationships(
ontology: Ontology,
errors: ValidationError[],
warnings: ValidationError[],
) {
const concepts = ontology.concepts;
Object.values(concepts).forEach((concept) => {
// Validate broader concept exists
if (concept.broader && !concepts[concept.broader]) {
errors.push({
type: "error",
code: "CONCEPT_INVALID_BROADER",
message: `Concept "${concept.prefLabel}" references non-existent broader concept: ${concept.broader}`,
conceptId: concept.id,
});
}
// Validate narrower concepts exist
if (concept.narrower) {
concept.narrower.forEach((narrowerId) => {
if (!concepts[narrowerId]) {
errors.push({
type: "error",
code: "CONCEPT_INVALID_NARROWER",
message: `Concept "${concept.prefLabel}" references non-existent narrower concept: ${narrowerId}`,
conceptId: concept.id,
});
}
});
}
// Validate related concepts exist
if (concept.related) {
concept.related.forEach((relatedId) => {
if (!concepts[relatedId]) {
errors.push({
type: "error",
code: "CONCEPT_INVALID_RELATED",
message: `Concept "${concept.prefLabel}" references non-existent related concept: ${relatedId}`,
conceptId: concept.id,
});
}
});
}
// Check for self-references
if (concept.broader === concept.id) {
errors.push({
type: "error",
code: "CONCEPT_SELF_BROADER",
message: `Concept "${concept.prefLabel}" cannot be broader than itself`,
conceptId: concept.id,
});
}
if (concept.narrower?.includes(concept.id)) {
errors.push({
type: "error",
code: "CONCEPT_SELF_NARROWER",
message: `Concept "${concept.prefLabel}" cannot be narrower than itself`,
conceptId: concept.id,
});
}
if (concept.related?.includes(concept.id)) {
errors.push({
type: "error",
code: "CONCEPT_SELF_RELATED",
message: `Concept "${concept.prefLabel}" cannot be related to itself`,
conceptId: concept.id,
});
}
// Check for broader/narrower consistency
if (concept.broader && concepts[concept.broader]) {
const broaderConcept = concepts[concept.broader];
if (!broaderConcept.narrower?.includes(concept.id)) {
warnings.push({
type: "warning",
code: "RELATIONSHIP_INCONSISTENT_BROADER",
message: `Concept "${concept.prefLabel}" claims "${broaderConcept.prefLabel}" as broader, but broader concept doesn't list it as narrower`,
conceptId: concept.id,
});
}
}
// Check for narrower/broader consistency
if (concept.narrower) {
concept.narrower.forEach((narrowerId) => {
const narrowerConcept = concepts[narrowerId];
if (narrowerConcept && narrowerConcept.broader !== concept.id) {
warnings.push({
type: "warning",
code: "RELATIONSHIP_INCONSISTENT_NARROWER",
message: `Concept "${concept.prefLabel}" claims "${narrowerConcept.prefLabel}" as narrower, but narrower concept doesn't list it as broader`,
conceptId: concept.id,
});
}
});
}
// Check for related relationship symmetry
if (concept.related) {
concept.related.forEach((relatedId) => {
const relatedConcept = concepts[relatedId];
if (
relatedConcept &&
!relatedConcept.related?.includes(concept.id)
) {
warnings.push({
type: "warning",
code: "RELATIONSHIP_ASYMMETRIC_RELATED",
message: `Concept "${concept.prefLabel}" is related to "${relatedConcept.prefLabel}", but the relationship is not symmetric`,
conceptId: concept.id,
});
}
});
}
});
}
private validateHierarchy(
ontology: Ontology,
errors: ValidationError[],
warnings: ValidationError[],
info: ValidationError[],
) {
const concepts = ontology.concepts;
// Check for circular references in broader/narrower relationships
Object.values(concepts).forEach((concept) => {
if (concept.broader) {
const visited = new Set<string>();
let current = concept.id;
while (current && concepts[current]?.broader) {
if (visited.has(current)) {
errors.push({
type: "error",
code: "HIERARCHY_CIRCULAR_REFERENCE",
message: `Circular reference detected in hierarchy starting from concept "${concept.prefLabel}"`,
conceptId: concept.id,
details: { path: Array.from(visited) },
});
break;
}
visited.add(current);
current = concepts[current].broader!;
}
}
});
// Check for orphaned concepts (no broader and not a top concept)
const topConceptIds = new Set(ontology.scheme.hasTopConcept);
const explicitTopConcepts = new Set(
Object.values(concepts)
.filter((c) => c.topConcept)
.map((c) => c.id),
);
Object.values(concepts).forEach((concept) => {
const hasNoBroader = !concept.broader;
const isNotTopConcept =
!topConceptIds.has(concept.id) && !explicitTopConcepts.has(concept.id);
if (hasNoBroader && isNotTopConcept) {
warnings.push({
type: "warning",
code: "HIERARCHY_ORPHANED_CONCEPT",
message: `Concept "${concept.prefLabel}" has no broader concept and is not marked as a top concept`,
conceptId: concept.id,
});
}
});
// Check for disconnected subhierarchies
const connectedConcepts = this.findConnectedConcepts(ontology);
const allConceptIds = new Set(Object.keys(concepts));
const disconnected = new Set(
[...allConceptIds].filter((id) => !connectedConcepts.has(id)),
);
if (disconnected.size > 0) {
info.push({
type: "info",
code: "HIERARCHY_DISCONNECTED_CONCEPTS",
message: `Found ${disconnected.size} concepts that are not connected to the main hierarchy`,
details: { conceptIds: Array.from(disconnected) },
});
}
}
private findConnectedConcepts(ontology: Ontology): Set<string> {
const connected = new Set<string>();
const concepts = ontology.concepts;
const topConcepts = ontology.scheme.hasTopConcept;
// Start from top concepts and traverse down
const toVisit = [...topConcepts];
while (toVisit.length > 0) {
const conceptId = toVisit.pop()!;
if (connected.has(conceptId) || !concepts[conceptId]) continue;
connected.add(conceptId);
// Add narrower concepts to visit
if (concepts[conceptId].narrower) {
toVisit.push(...concepts[conceptId].narrower!);
}
// Also add related concepts
if (concepts[conceptId].related) {
toVisit.push(...concepts[conceptId].related!);
}
}
return connected;
}
private isValidURI(uri: string): boolean {
try {
new URL(uri);
return true;
} catch {
return false;
}
}
}
// Convenience functions
export const skosValidator = new SKOSValidator();
export function validateOntology(ontology: Ontology): ValidationResult {
return skosValidator.validate(ontology);
}
// Format detection utilities
export function detectSKOSFormat(
content: string,
): "rdf" | "turtle" | "unknown" {
const trimmed = content.trim();
// Check for XML declaration or RDF root element
if (
trimmed.startsWith("<?xml") ||
trimmed.includes("<rdf:RDF") ||
trimmed.includes("<RDF")
) {
return "rdf";
}
// Check for Turtle prefixes
if (
trimmed.includes("@prefix") ||
trimmed.includes("@base") ||
/^\s*<[^>]+>\s+a\s+/.test(trimmed)
) {
return "turtle";
}
return "unknown";
}
export function isSKOSContent(content: string): boolean {
const lowercased = content.toLowerCase();
return (
lowercased.includes("skos:") ||
lowercased.includes("skos/core") ||
lowercased.includes("conceptscheme") ||
lowercased.includes("concept") ||
lowercased.includes("preflabel")
);
}

613
src/utils/skos.ts Normal file
View file

@ -0,0 +1,613 @@
/**
* SKOS (Simple Knowledge Organization System) Parser and Serializer
*
* This module handles conversion between our internal ontology format
* and standard SKOS RDF/XML and Turtle formats for interoperability.
*/
import {
Ontology,
OntologyConcept,
OntologyScheme,
} from "@trustgraph/react-state";
// SKOS namespace constants
export const SKOS_NAMESPACES = {
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
skos: "http://www.w3.org/2004/02/skos/core#",
dc: "http://purl.org/dc/terms/",
dcterms: "http://purl.org/dc/terms/",
} as const;
// SKOS RDF/XML serialization
export class SKOSSerializer {
private baseURI: string;
constructor(baseURI?: string) {
this.baseURI = baseURI || "http://example.org/ontology/";
}
/**
* Convert internal ontology format to SKOS RDF/XML
*/
toRDF(ontology: Ontology): string {
const concepts = Object.values(ontology.concepts);
const scheme = ontology.scheme;
const rdf = [];
// RDF/XML header with namespaces
rdf.push('<?xml version="1.0" encoding="UTF-8"?>');
rdf.push("<rdf:RDF");
rdf.push(` xmlns:rdf="${SKOS_NAMESPACES.rdf}"`);
rdf.push(` xmlns:skos="${SKOS_NAMESPACES.skos}"`);
rdf.push(` xmlns:dc="${SKOS_NAMESPACES.dc}"`);
rdf.push(` xmlns:dcterms="${SKOS_NAMESPACES.dcterms}">`);
rdf.push("");
// Concept Scheme
rdf.push(` <skos:ConceptScheme rdf:about="${scheme.uri}">`);
rdf.push(
` <skos:prefLabel xml:lang="en">${this.escapeXML(scheme.prefLabel)}</skos:prefLabel>`,
);
if (ontology.metadata.description) {
rdf.push(
` <dc:description xml:lang="en">${this.escapeXML(ontology.metadata.description)}</dc:description>`,
);
}
rdf.push(
` <dcterms:created>${ontology.metadata.created}</dcterms:created>`,
);
rdf.push(
` <dcterms:modified>${ontology.metadata.modified}</dcterms:modified>`,
);
rdf.push(
` <dc:creator>${this.escapeXML(ontology.metadata.creator)}</dc:creator>`,
);
// Top concepts
scheme.hasTopConcept.forEach((conceptId) => {
rdf.push(
` <skos:hasTopConcept rdf:resource="${this.getConceptURI(conceptId)}" />`,
);
});
rdf.push(" </skos:ConceptScheme>");
rdf.push("");
// Concepts
concepts.forEach((concept) => {
rdf.push(
` <skos:Concept rdf:about="${this.getConceptURI(concept.id)}">`,
);
rdf.push(` <skos:inScheme rdf:resource="${scheme.uri}" />`);
rdf.push(
` <skos:prefLabel xml:lang="en">${this.escapeXML(concept.prefLabel)}</skos:prefLabel>`,
);
// Alternative labels
if (concept.altLabel) {
concept.altLabel.forEach((altLabel) => {
rdf.push(
` <skos:altLabel xml:lang="en">${this.escapeXML(altLabel)}</skos:altLabel>`,
);
});
}
// Definition
if (concept.definition) {
rdf.push(
` <skos:definition xml:lang="en">${this.escapeXML(concept.definition)}</skos:definition>`,
);
}
// Scope note
if (concept.scopeNote) {
rdf.push(
` <skos:scopeNote xml:lang="en">${this.escapeXML(concept.scopeNote)}</skos:scopeNote>`,
);
}
// Examples
if (concept.example) {
concept.example.forEach((example) => {
rdf.push(
` <skos:example xml:lang="en">${this.escapeXML(example)}</skos:example>`,
);
});
}
// Notation
if (concept.notation) {
rdf.push(
` <skos:notation>${this.escapeXML(concept.notation)}</skos:notation>`,
);
}
// Broader concept
if (concept.broader) {
rdf.push(
` <skos:broader rdf:resource="${this.getConceptURI(concept.broader)}" />`,
);
}
// Narrower concepts
if (concept.narrower) {
concept.narrower.forEach((narrowerId) => {
rdf.push(
` <skos:narrower rdf:resource="${this.getConceptURI(narrowerId)}" />`,
);
});
}
// Related concepts
if (concept.related) {
concept.related.forEach((relatedId) => {
rdf.push(
` <skos:related rdf:resource="${this.getConceptURI(relatedId)}" />`,
);
});
}
// Top concept marker
if (concept.topConcept) {
rdf.push(` <skos:topConceptOf rdf:resource="${scheme.uri}" />`);
}
rdf.push(" </skos:Concept>");
rdf.push("");
});
rdf.push("</rdf:RDF>");
return rdf.join("\n");
}
/**
* Convert internal ontology format to SKOS Turtle
*/
toTurtle(ontology: Ontology): string {
const concepts = Object.values(ontology.concepts);
const scheme = ontology.scheme;
const ttl = [];
// Prefixes
ttl.push("@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .");
ttl.push("@prefix skos: <http://www.w3.org/2004/02/skos/core#> .");
ttl.push("@prefix dc: <http://purl.org/dc/terms/> .");
ttl.push("@prefix dcterms: <http://purl.org/dc/terms/> .");
ttl.push("");
// Concept Scheme
ttl.push(`<${scheme.uri}>`);
ttl.push(" a skos:ConceptScheme ;");
ttl.push(` skos:prefLabel "${this.escapeTurtle(scheme.prefLabel)}"@en ;`);
if (ontology.metadata.description) {
ttl.push(
` dc:description "${this.escapeTurtle(ontology.metadata.description)}"@en ;`,
);
}
ttl.push(` dcterms:created "${ontology.metadata.created}" ;`);
ttl.push(` dcterms:modified "${ontology.metadata.modified}" ;`);
ttl.push(
` dc:creator "${this.escapeTurtle(ontology.metadata.creator)}" ;`,
);
// Top concepts
if (scheme.hasTopConcept.length > 0) {
const topConcepts = scheme.hasTopConcept
.map((id) => `<${this.getConceptURI(id)}>`)
.join(", ");
ttl.push(` skos:hasTopConcept ${topConcepts} ;`);
}
// Remove trailing semicolon and add period
const lastLine = ttl[ttl.length - 1];
ttl[ttl.length - 1] = lastLine.replace(/;$/, " .");
ttl.push("");
// Concepts
concepts.forEach((concept) => {
ttl.push(`<${this.getConceptURI(concept.id)}>`);
ttl.push(" a skos:Concept ;");
ttl.push(` skos:inScheme <${scheme.uri}> ;`);
ttl.push(
` skos:prefLabel "${this.escapeTurtle(concept.prefLabel)}"@en ;`,
);
// Alternative labels
if (concept.altLabel && concept.altLabel.length > 0) {
concept.altLabel.forEach((altLabel) => {
ttl.push(` skos:altLabel "${this.escapeTurtle(altLabel)}"@en ;`);
});
}
// Definition
if (concept.definition) {
ttl.push(
` skos:definition "${this.escapeTurtle(concept.definition)}"@en ;`,
);
}
// Scope note
if (concept.scopeNote) {
ttl.push(
` skos:scopeNote "${this.escapeTurtle(concept.scopeNote)}"@en ;`,
);
}
// Examples
if (concept.example && concept.example.length > 0) {
concept.example.forEach((example) => {
ttl.push(` skos:example "${this.escapeTurtle(example)}"@en ;`);
});
}
// Notation
if (concept.notation) {
ttl.push(` skos:notation "${this.escapeTurtle(concept.notation)}" ;`);
}
// Broader concept
if (concept.broader) {
ttl.push(` skos:broader <${this.getConceptURI(concept.broader)}> ;`);
}
// Narrower concepts
if (concept.narrower && concept.narrower.length > 0) {
const narrowerConcepts = concept.narrower
.map((id) => `<${this.getConceptURI(id)}>`)
.join(", ");
ttl.push(` skos:narrower ${narrowerConcepts} ;`);
}
// Related concepts
if (concept.related && concept.related.length > 0) {
const relatedConcepts = concept.related
.map((id) => `<${this.getConceptURI(id)}>`)
.join(", ");
ttl.push(` skos:related ${relatedConcepts} ;`);
}
// Top concept marker
if (concept.topConcept) {
ttl.push(` skos:topConceptOf <${scheme.uri}> ;`);
}
// Remove trailing semicolon and add period
const lastLine = ttl[ttl.length - 1];
ttl[ttl.length - 1] = lastLine.replace(/;$/, " .");
ttl.push("");
});
return ttl.join("\n");
}
private getConceptURI(conceptId: string): string {
return this.baseURI + conceptId;
}
private escapeXML(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
private escapeTurtle(text: string): string {
return text
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
}
// SKOS Parser for importing
export class SKOSParser {
/**
* Parse SKOS RDF/XML and convert to internal ontology format
*/
async parseRDF(rdfXML: string, ontologyId: string): Promise<Ontology> {
// For a complete implementation, we'd use a proper RDF parser like rdflib.js
// For now, we'll implement a simplified parser using DOM parsing
const parser = new DOMParser();
const doc = parser.parseFromString(rdfXML, "text/xml");
// Check for parsing errors
if (doc.documentElement.nodeName === "parsererror") {
throw new Error("Invalid XML format");
}
const concepts: Record<string, OntologyConcept> = {};
let scheme: OntologyScheme | null = null;
const metadata = {
name: "",
description: "",
version: "1.0",
created: new Date().toISOString(),
modified: new Date().toISOString(),
creator: "Imported",
namespace: "",
};
// Parse ConceptScheme
const conceptSchemes = doc.getElementsByTagNameNS(
SKOS_NAMESPACES.skos,
"ConceptScheme",
);
if (conceptSchemes.length > 0) {
const schemeElement = conceptSchemes[0];
const uri =
schemeElement.getAttribute("rdf:about") ||
schemeElement.getAttributeNS(SKOS_NAMESPACES.rdf, "about") ||
"";
const prefLabel =
this.getTextContent(schemeElement, "skos:prefLabel") ||
"Imported Ontology";
const description =
this.getTextContent(schemeElement, "dc:description") ||
this.getTextContent(schemeElement, "dcterms:description") ||
"";
metadata.name = prefLabel;
metadata.description = description;
metadata.namespace = uri;
const created = this.getTextContent(schemeElement, "dcterms:created");
const modified = this.getTextContent(schemeElement, "dcterms:modified");
const creator = this.getTextContent(schemeElement, "dc:creator");
if (created) metadata.created = created;
if (modified) metadata.modified = modified;
if (creator) metadata.creator = creator;
// Get top concepts
const topConceptElements = schemeElement.getElementsByTagNameNS(
SKOS_NAMESPACES.skos,
"hasTopConcept",
);
const hasTopConcept = Array.from(topConceptElements).map((el) => {
const resource =
el.getAttribute("rdf:resource") ||
el.getAttributeNS(SKOS_NAMESPACES.rdf, "resource") ||
"";
return this.extractConceptId(resource);
});
scheme = {
uri,
prefLabel,
hasTopConcept,
};
}
// Parse Concepts
const conceptElements = doc.getElementsByTagNameNS(
SKOS_NAMESPACES.skos,
"Concept",
);
Array.from(conceptElements).forEach((conceptElement) => {
const conceptURI =
conceptElement.getAttribute("rdf:about") ||
conceptElement.getAttributeNS(SKOS_NAMESPACES.rdf, "about") ||
"";
const conceptId = this.extractConceptId(conceptURI);
const prefLabel =
this.getTextContent(conceptElement, "skos:prefLabel") || conceptId;
const definition = this.getTextContent(
conceptElement,
"skos:definition",
);
const scopeNote = this.getTextContent(conceptElement, "skos:scopeNote");
const notation = this.getTextContent(conceptElement, "skos:notation");
// Get alternative labels
const altLabels = this.getAllTextContent(
conceptElement,
"skos:altLabel",
);
// Get examples
const examples = this.getAllTextContent(conceptElement, "skos:example");
// Get broader concept
const broaderElements = conceptElement.getElementsByTagNameNS(
SKOS_NAMESPACES.skos,
"broader",
);
const broader =
broaderElements.length > 0
? this.extractConceptId(
broaderElements[0].getAttribute("rdf:resource") ||
broaderElements[0].getAttributeNS(
SKOS_NAMESPACES.rdf,
"resource",
) ||
"",
)
: null;
// Get narrower concepts
const narrowerElements = conceptElement.getElementsByTagNameNS(
SKOS_NAMESPACES.skos,
"narrower",
);
const narrower = Array.from(narrowerElements).map((el) => {
const resource =
el.getAttribute("rdf:resource") ||
el.getAttributeNS(SKOS_NAMESPACES.rdf, "resource") ||
"";
return this.extractConceptId(resource);
});
// Get related concepts
const relatedElements = conceptElement.getElementsByTagNameNS(
SKOS_NAMESPACES.skos,
"related",
);
const related = Array.from(relatedElements).map((el) => {
const resource =
el.getAttribute("rdf:resource") ||
el.getAttributeNS(SKOS_NAMESPACES.rdf, "resource") ||
"";
return this.extractConceptId(resource);
});
// Check if it's a top concept
const topConceptOfElements = conceptElement.getElementsByTagNameNS(
SKOS_NAMESPACES.skos,
"topConceptOf",
);
const topConcept = topConceptOfElements.length > 0;
const concept: OntologyConcept = {
id: conceptId,
prefLabel,
broader: broader || null,
narrower,
related,
topConcept,
};
if (altLabels.length > 0) concept.altLabel = altLabels;
if (definition) concept.definition = definition;
if (scopeNote) concept.scopeNote = scopeNote;
if (examples.length > 0) concept.example = examples;
if (notation) concept.notation = notation;
concepts[conceptId] = concept;
});
// If no scheme was found, create a default one
if (!scheme) {
scheme = {
uri: metadata.namespace || `http://example.org/ontology/${ontologyId}`,
prefLabel: metadata.name || "Imported Ontology",
hasTopConcept: Object.values(concepts)
.filter((c) => c.topConcept)
.map((c) => c.id),
};
}
return {
metadata,
concepts,
scheme,
};
}
/**
* Parse SKOS Turtle and convert to internal ontology format
*/
async parseTurtle(): Promise<Ontology> {
// For a complete implementation, we'd use a proper Turtle parser
// This is a placeholder for the Turtle parsing functionality
throw new Error(
"Turtle parsing not yet implemented. Please use RDF/XML format.",
);
}
private getTextContent(element: Element, selector: string): string | null {
const [prefix, localName] = selector.split(":");
// Get namespace URI based on prefix
let namespaceURI = "";
switch (prefix) {
case "skos":
namespaceURI = SKOS_NAMESPACES.skos;
break;
case "dc":
namespaceURI = SKOS_NAMESPACES.dc;
break;
case "dcterms":
namespaceURI = SKOS_NAMESPACES.dcterms;
break;
default:
namespaceURI = "";
}
// Try to find element using namespace
let el: Element | null = null;
if (namespaceURI) {
const elements = element.getElementsByTagNameNS(namespaceURI, localName);
el = elements.length > 0 ? elements[0] : null;
}
// Fallback to querySelector with localName
if (!el) {
el = element.querySelector(`*[localName="${localName}"]`);
}
return el?.textContent?.trim() || null;
}
private getAllTextContent(element: Element, selector: string): string[] {
const [prefix, localName] = selector.split(":");
// Get namespace URI based on prefix
let namespaceURI = "";
switch (prefix) {
case "skos":
namespaceURI = SKOS_NAMESPACES.skos;
break;
case "dc":
namespaceURI = SKOS_NAMESPACES.dc;
break;
case "dcterms":
namespaceURI = SKOS_NAMESPACES.dcterms;
break;
default:
namespaceURI = "";
}
// Try to find elements using namespace
let elements: HTMLCollectionOf<Element> | NodeListOf<Element>;
if (namespaceURI) {
elements = element.getElementsByTagNameNS(namespaceURI, localName);
} else {
elements = element.querySelectorAll(`*[localName="${localName}"]`);
}
return Array.from(elements)
.map((el) => el.textContent?.trim() || "")
.filter((text) => text.length > 0);
}
private extractConceptId(uri: string): string {
// Extract the concept ID from the URI (everything after the last # or /)
const match = uri.match(/[#/]([^#/]+)$/);
return match ? match[1] : uri;
}
}
// Convenience functions
export const skosSerializer = new SKOSSerializer();
export const skosParser = new SKOSParser();
// Export functions
export function serializeToSKOS(
ontology: Ontology,
format: "rdf" | "turtle" = "rdf",
): string {
if (format === "turtle") {
return skosSerializer.toTurtle(ontology);
}
return skosSerializer.toRDF(ontology);
}
export async function parseFromSKOS(
content: string,
ontologyId: string,
format: "rdf" | "turtle" = "rdf",
): Promise<Ontology> {
if (format === "turtle") {
return skosParser.parseTurtle(content, ontologyId);
}
return skosParser.parseRDF(content, ontologyId);
}

4
src/utils/time-string.ts Normal file
View file

@ -0,0 +1,4 @@
export const timeString = (time) => {
const tm = new Date(time * 1000);
return tm.toLocaleDateString() + " " + tm.toLocaleTimeString();
};

View file

@ -0,0 +1,56 @@
import {
getGraphEmbeddings,
addRowLabels,
addRowDefinitions,
addRowEmbeddings,
computeCosineSimilarity,
sortSimilarity,
} from "./row";
export const vectorSearch = (
socket,
flowId,
addActivity,
removeActivity,
term: string,
collection?: string,
limit?: number,
) => {
const api = socket.flow(flowId);
const searchAct = "Search: " + term;
addActivity(searchAct);
return api
.embeddings(term)
.then(
getGraphEmbeddings(
api,
addActivity,
removeActivity,
limit || 10,
collection,
),
)
.then(addRowLabels(api, addActivity, removeActivity, collection))
.then(addRowDefinitions(api, addActivity, removeActivity, collection))
.then(addRowEmbeddings(api, addActivity, removeActivity))
.then(computeCosineSimilarity(addActivity, removeActivity))
.then(sortSimilarity(addActivity, removeActivity))
.then((x) => {
removeActivity(searchAct);
return {
view: x,
entities: x.map((row) => {
return {
uri: row.uri,
label: row.label ? row.label : "n/a",
};
}),
};
})
.catch((err) => {
removeActivity(searchAct);
throw err;
});
};