mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-02 22:41:01 +02:00
Merge commit 'a8390532f7' as 'ai-context/workbench-ui'
This commit is contained in:
commit
1a72bfdec0
310 changed files with 56430 additions and 0 deletions
|
|
@ -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==");
|
||||
});
|
||||
});
|
||||
173
ai-context/workbench-ui/src/utils/__tests__/row.test.ts
Normal file
173
ai-context/workbench-ui/src/utils/__tests__/row.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
333
ai-context/workbench-ui/src/utils/__tests__/skos.test.ts
Normal file
333
ai-context/workbench-ui/src/utils/__tests__/skos.test.ts
Normal 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 & Example <tag>");
|
||||
expect(rdf).toContain(
|
||||
"Definition with "quotes" and 'apostrophes'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
49
ai-context/workbench-ui/src/utils/document-encoding.ts
Normal file
49
ai-context/workbench-ui/src/utils/document-encoding.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
0
ai-context/workbench-ui/src/utils/document-load.ts
Normal file
0
ai-context/workbench-ui/src/utils/document-load.ts
Normal file
374
ai-context/workbench-ui/src/utils/export-formats.ts
Normal file
374
ai-context/workbench-ui/src/utils/export-formats.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
167
ai-context/workbench-ui/src/utils/knowledge-graph-viz.ts
Normal file
167
ai-context/workbench-ui/src/utils/knowledge-graph-viz.ts
Normal 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));
|
||||
};
|
||||
333
ai-context/workbench-ui/src/utils/knowledge-graph.ts
Normal file
333
ai-context/workbench-ui/src/utils/knowledge-graph.ts
Normal 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
ai-context/workbench-ui/src/utils/ontology-qa.ts
Normal file
480
ai-context/workbench-ui/src/utils/ontology-qa.ts
Normal 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
ai-context/workbench-ui/src/utils/row.ts
Normal file
192
ai-context/workbench-ui/src/utils/row.ts
Normal 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;
|
||||
};
|
||||
75
ai-context/workbench-ui/src/utils/schema-validation.ts
Normal file
75
ai-context/workbench-ui/src/utils/schema-validation.ts
Normal 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;
|
||||
};
|
||||
458
ai-context/workbench-ui/src/utils/skos-validation.ts
Normal file
458
ai-context/workbench-ui/src/utils/skos-validation.ts
Normal 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
ai-context/workbench-ui/src/utils/skos.ts
Normal file
613
ai-context/workbench-ui/src/utils/skos.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
ai-context/workbench-ui/src/utils/time-string.ts
Normal file
4
ai-context/workbench-ui/src/utils/time-string.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const timeString = (time) => {
|
||||
const tm = new Date(time * 1000);
|
||||
return tm.toLocaleDateString() + " " + tm.toLocaleTimeString();
|
||||
};
|
||||
56
ai-context/workbench-ui/src/utils/vector-search.ts
Normal file
56
ai-context/workbench-ui/src/utils/vector-search.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue