mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-10 04:00:35 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
702e392889 |
@@ -9,7 +9,7 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
|
||||
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/markdownEditorInteraction.test.tsx tests/markdownEditorState.test.ts tests/nodeMarkdownSync.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/temporalScrubberBounds.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts tests/graphColorLegend.test.ts tests/ontologyUrlState.test.ts",
|
||||
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/markdownEditorInteraction.test.tsx tests/markdownEditorState.test.ts tests/nodeMarkdownSync.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/temporalScrubberBounds.test.ts tests/deterministicExplorerRendering.test.ts tests/explorerCapabilities.test.tsx tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts tests/graphColorLegend.test.ts",
|
||||
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
|
||||
"test:graph-legend-e2e": "node --import tsx --test tests/graphColorLegend.e2e.ts",
|
||||
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { ExploreWorkspaceTabs, type ExploreView } from './ExploreWorkspaceTabs';
|
||||
import { fetchAgentMemoryAvailability } from './explorerCapabilities';
|
||||
import { hasOntologyUrlState } from './workspaces/OntologyWorkspace/ontologyUrlState';
|
||||
|
||||
const DecisionWorkspace = lazy(() => import('./workspaces/DecisionWorkspace/DecisionWorkspace').then((module) => ({ default: module.DecisionWorkspace })));
|
||||
const DiffMergeWorkspace = lazy(() => import('./workspaces/DiffMergeWorkspace/DiffMergeWorkspace').then((module) => ({ default: module.DiffMergeWorkspace })));
|
||||
@@ -97,7 +96,15 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
function readInitialWorkspace(): WorkspaceId {
|
||||
return hasOntologyUrlState() ? 'ontology-hub' : 'welcome';
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("ontologyTab") || params.has("ontologyEntity")) {
|
||||
return "ontology-hub";
|
||||
}
|
||||
} catch {
|
||||
// Default to the welcome screen when URL state is unavailable.
|
||||
}
|
||||
return "welcome";
|
||||
}
|
||||
|
||||
const shellStyles = `
|
||||
|
||||
@@ -28,12 +28,11 @@ import { loadOntologyEntityOwner, loadOntologyGraph } from "./api";
|
||||
import type { OntologyGraphEdge, OntologyGraphNode } from "./api";
|
||||
import {
|
||||
classifyNodeType,
|
||||
inferOntologyUri,
|
||||
isEditableEntityType,
|
||||
ONTOLOGY_MINIMAP_THEME,
|
||||
resolveEditorOntology,
|
||||
} from "./ontologyEditorModel";
|
||||
import type { EditorEntityType, RegistryEntry } from "./ontologyEditorModel";
|
||||
import { clearEntitySelection, readOntologyUrlState, writeEntitySelection } from "./ontologyUrlState";
|
||||
|
||||
type OntologyNodeData = {
|
||||
label?: string;
|
||||
@@ -138,7 +137,11 @@ interface DraftDiff {
|
||||
}
|
||||
|
||||
function requestedEntityUri(): string {
|
||||
return readOntologyUrlState().entityUri || "";
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get("ontologyEntity") || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function nodeLabel(node: OntologyGraphNode): string {
|
||||
@@ -222,7 +225,6 @@ export function OntologyEditor() {
|
||||
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<OntologyNode, OntologyEdge> | null>(null);
|
||||
const [isLoadingGraph, setIsLoadingGraph] = useState(false);
|
||||
const [graphError, setGraphError] = useState("");
|
||||
const [unownedEntity, setUnownedEntity] = useState("");
|
||||
const [draftDiff, setDraftDiff] = useState<DraftDiff>({
|
||||
added_classes: [],
|
||||
removed_classes: [],
|
||||
@@ -248,21 +250,11 @@ export function OntologyEditor() {
|
||||
? loadOntologyEntityOwner(requested).catch(() => undefined)
|
||||
: Promise.resolve(undefined),
|
||||
])
|
||||
.then(([entries, ownerVerdict]: [RegistryEntry[], string | null | undefined]) => {
|
||||
.then(([entries, explicitOwner]: [RegistryEntry[], string | undefined]) => {
|
||||
if (cancelled) return;
|
||||
setRegistry(entries);
|
||||
const resolution = resolveEditorOntology(entries, requested, ownerVerdict);
|
||||
// The registry default is the right landing place for "no entity asked
|
||||
// for", but not for "the backend says nothing owns the entity that was
|
||||
// asked for" — that would open an arbitrary ontology whose graph
|
||||
// excludes the entity, and report nothing about why.
|
||||
if (resolution.status === "unowned") {
|
||||
setUnownedEntity(resolution.entityUri);
|
||||
return;
|
||||
}
|
||||
setUnownedEntity("");
|
||||
const resolvedOntology = resolution.status === "resolved" ? resolution.uri : undefined;
|
||||
setOntologyUri((current) => current || resolvedOntology || entries[0]?.uri || "");
|
||||
const inferredOntology = inferOntologyUri(entries, requested, explicitOwner);
|
||||
setOntologyUri((current) => current || inferredOntology || entries[0]?.uri || "");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load ontology registry:", error);
|
||||
@@ -385,7 +377,14 @@ export function OntologyEditor() {
|
||||
|
||||
const selectNode = useCallback((node: OntologyNode) => {
|
||||
setSelectedElement(node);
|
||||
writeEntitySelection(node.id);
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("ontologyTab", "editor");
|
||||
params.set("ontologyEntity", node.id);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} catch {
|
||||
// URL state is optional; the editor selection still works without it.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveDraft = useCallback(async () => {
|
||||
@@ -555,8 +554,15 @@ export function OntologyEditor() {
|
||||
onChange={(event) => {
|
||||
setOntologyUri(event.target.value);
|
||||
setSelectedElement(null);
|
||||
setUnownedEntity("");
|
||||
clearEntitySelection();
|
||||
try {
|
||||
// Drop the previous ontology's entity from the URL, or a reload
|
||||
// would resolve the stale ID and jump back to that ontology.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("ontologyEntity");
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} catch {
|
||||
// URL state is optional; switching ontologies still works.
|
||||
}
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
@@ -627,12 +633,7 @@ export function OntologyEditor() {
|
||||
{!isLoadingGraph && graphError && (
|
||||
<div style={{ ...canvasMessageStyle, color: "#ff9a8d" }}>{graphError}</div>
|
||||
)}
|
||||
{!isLoadingGraph && !graphError && unownedEntity && (
|
||||
<div style={{ ...canvasMessageStyle, color: "#f2b66d" }}>
|
||||
No registered ontology owns {unownedEntity}. Pick an ontology above to start editing.
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingGraph && !graphError && !unownedEntity && ontologyUri && nodes.length === 0 && (
|
||||
{!isLoadingGraph && !graphError && ontologyUri && nodes.length === 0 && (
|
||||
<div style={canvasMessageStyle}>This ontology has no editable classes or properties.</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -32,11 +32,7 @@ export type OntologyGraphResponse = {
|
||||
};
|
||||
|
||||
export type OntologyEntityOwner = {
|
||||
// Optional on purpose, unlike OntologyGraphNode.entity_type. There, a missing
|
||||
// field degrades to a read-only node — benign. Here it would be read as an
|
||||
// authoritative "nothing owns this entity", which now suppresses selection
|
||||
// outright, so presence has to be checked rather than assumed.
|
||||
owning_ontology?: string | null;
|
||||
source_ontology?: string;
|
||||
};
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
@@ -67,21 +63,10 @@ export async function loadOntologyGraph(uri: string, signal?: AbortSignal): Prom
|
||||
);
|
||||
}
|
||||
|
||||
// Three-state verdict: a string names the owner, null is the backend's
|
||||
// authoritative "no known ontology owns this entity", and undefined means the
|
||||
// request failed so there is no verdict to act on.
|
||||
export type OntologyOwnerVerdict = string | null | undefined;
|
||||
|
||||
export async function loadOntologyEntityOwner(uri: string): Promise<OntologyOwnerVerdict> {
|
||||
export async function loadOntologyEntityOwner(uri: string): Promise<string | undefined> {
|
||||
const response = await fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`);
|
||||
if (!response.ok) return undefined;
|
||||
const owner = await response.json() as OntologyEntityOwner | null;
|
||||
// Only a field that is actually there carries the verdict. Coercing an absent
|
||||
// field to null would assert the strongest available claim — "nothing owns
|
||||
// this" — on the weakest possible evidence, and that claim now stops the
|
||||
// editor selecting an ontology at all.
|
||||
const verdict = owner?.owning_ontology;
|
||||
return verdict === undefined ? undefined : verdict;
|
||||
return (await response.json() as OntologyEntityOwner).source_ontology;
|
||||
}
|
||||
|
||||
export async function loadAlignments(uri?: string): Promise<OntologyAlignment[]> {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { OntologyManager } from "./OntologyManager";
|
||||
import { OntologyEditor } from "./OntologyEditor";
|
||||
import { ShaclStudio } from "./ShaclStudio";
|
||||
import { VersionsTab } from "./VersionsTab";
|
||||
import { readOntologyUrlState, writeEntitySelection, writeTab } from "./ontologyUrlState";
|
||||
|
||||
export type OntologyHubTab =
|
||||
| "registry"
|
||||
@@ -23,6 +22,8 @@ export type OntologyHubTab =
|
||||
| "health"
|
||||
| "shacl";
|
||||
|
||||
const TAB_PARAM = "ontologyTab";
|
||||
|
||||
const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
|
||||
{ id: "registry", label: "Registry", icon: BookMarked },
|
||||
{ id: "editor", label: "Editor", icon: Sliders },
|
||||
@@ -32,23 +33,37 @@ const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
|
||||
{ id: "shacl", label: "SHACL", icon: Shield },
|
||||
];
|
||||
|
||||
function readInitialTab(): OntologyHubTab {
|
||||
const { tab, entityUri } = readOntologyUrlState();
|
||||
const requested = TABS.find((candidate) => candidate.id === tab);
|
||||
if (requested) return requested.id;
|
||||
if (entityUri) return "editor";
|
||||
function readTabParam(): OntologyHubTab {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(TAB_PARAM);
|
||||
if (raw && TABS.some((t) => t.id === raw)) return raw as OntologyHubTab;
|
||||
if (params.get("ontologyEntity")) return "editor";
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "registry";
|
||||
}
|
||||
|
||||
function writeTabParam(tab: OntologyHubTab) {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(TAB_PARAM, tab);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
interface OntologyWorkspaceProps {
|
||||
onJumpToGraphNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps) {
|
||||
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readInitialTab);
|
||||
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readTabParam);
|
||||
|
||||
useEffect(() => {
|
||||
writeTab(activeTab);
|
||||
writeTabParam(activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleTabChange = useCallback((tab: OntologyHubTab) => {
|
||||
@@ -56,7 +71,10 @@ export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps)
|
||||
}, []);
|
||||
|
||||
const handleFixInEditor = useCallback((entityUri: string) => {
|
||||
writeEntitySelection(entityUri);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(TAB_PARAM, "editor");
|
||||
params.set("ontologyEntity", entityUri);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
setActiveTab("editor");
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -45,10 +45,6 @@ export function classifyNodeType(rawType: string): EditorEntityType {
|
||||
return "external";
|
||||
}
|
||||
|
||||
// Last-resort guess, reached only when the backend gave no verdict: it has no
|
||||
// notion of nested vocabularies, so it can name a parent that does not contain
|
||||
// the entity. Authority is owning_ontology from /api/ontology/entity
|
||||
// (_resolve_owning_ontology in semantica/explorer/routes/ontology.py).
|
||||
function ownsByNamespace(entityUri: string, ontologyUri: string): boolean {
|
||||
const stem = ontologyUri.replace(/[/#]+$/, "");
|
||||
return entityUri === ontologyUri
|
||||
@@ -56,50 +52,12 @@ function ownsByNamespace(entityUri: string, ontologyUri: string): boolean {
|
||||
|| entityUri.startsWith(`${stem}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Three outcomes, deliberately not collapsed into `string | undefined`.
|
||||
*
|
||||
* `unowned` and `unresolved` both yield "no ontology to open", but they must
|
||||
* not be treated alike: a caller that writes `resolve(...) || entries[0]` turns
|
||||
* the backend's authoritative "nothing owns this entity" into "open an
|
||||
* arbitrary ontology", which reintroduces the parent-selection bug this whole
|
||||
* verdict exists to prevent. The union makes that collapse a type error.
|
||||
*/
|
||||
export type EditorOntologyResolution =
|
||||
| { status: "resolved"; uri: string }
|
||||
| { status: "unowned"; entityUri: string }
|
||||
| { status: "unresolved" };
|
||||
|
||||
// Picks the registered ontology to open for a deep-linked entity. A null
|
||||
// verdict is the backend's authoritative "nothing owns this entity": the
|
||||
// namespace guess must stay suppressed, or an unregistered nested namespace
|
||||
// would select its registered parent again. Only an unavailable verdict
|
||||
// (undefined) may fall back to inference.
|
||||
export function resolveEditorOntology(
|
||||
entries: RegistryEntry[],
|
||||
entityUri: string,
|
||||
ownerVerdict: string | null | undefined,
|
||||
): EditorOntologyResolution {
|
||||
if (ownerVerdict === null) {
|
||||
return { status: "unowned", entityUri };
|
||||
}
|
||||
const uri = inferOntologyUri(entries, entityUri, ownerVerdict);
|
||||
return uri === undefined ? { status: "unresolved" } : { status: "resolved", uri };
|
||||
}
|
||||
|
||||
// Picks the registered ontology to open for an entity: the backend-resolved
|
||||
// explicitOwner wins outright, the namespace guess is only the fallback.
|
||||
export function inferOntologyUri(
|
||||
entries: RegistryEntry[],
|
||||
entityUri: string,
|
||||
explicitOwner?: string,
|
||||
): string | undefined {
|
||||
// Trusted even when the registry does not list it. Falling through to the
|
||||
// namespace guess here would answer a question nobody asked — the backend
|
||||
// named this entity's owner, and silently opening a *different* ontology is
|
||||
// worse than opening one the registry has not been told about yet, which
|
||||
// surfaces as an explicit error from /api/ontology/graph.
|
||||
if (explicitOwner) {
|
||||
if (explicitOwner && entries.some((entry) => entry.uri === explicitOwner)) {
|
||||
return explicitOwner;
|
||||
}
|
||||
return [...entries]
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// Sole owner of the Ontology Hub deep-link query parameters: the names below must not be
|
||||
// spelled out anywhere else, so that the protocol can change in one place.
|
||||
const TAB_PARAM = "ontologyTab";
|
||||
const ENTITY_PARAM = "ontologyEntity";
|
||||
const EDITOR_TAB = "editor";
|
||||
|
||||
export interface OntologyUrlState {
|
||||
/** Raw parameter value; the set of legal tab ids belongs to the workspace, not this module. */
|
||||
tab?: string;
|
||||
entityUri?: string;
|
||||
}
|
||||
|
||||
/** `undefined` means the parameter is absent; an empty string means it is present but blank. */
|
||||
export function parseOntologyUrlState(search: string): OntologyUrlState {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
tab: params.get(TAB_PARAM) ?? undefined,
|
||||
entityUri: params.get(ENTITY_PARAM) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTab(search: string, tab: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.set(TAB_PARAM, tab);
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
// A selected entity is only addressable from the editor, so the tab moves with it.
|
||||
export function applyEntitySelection(search: string, entityUri: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.set(TAB_PARAM, EDITOR_TAB);
|
||||
params.set(ENTITY_PARAM, entityUri);
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Pairs with applyEntitySelection: an entity URI is resolved back to its owning ontology on
|
||||
// load, so leaving a stale one behind when the active ontology changes reopens the old ontology.
|
||||
export function removeEntitySelection(search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete(ENTITY_PARAM);
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately dual-role, and the argument is what selects the role: given a
|
||||
* `search` string this is pure and total, delegating straight to
|
||||
* `parseOntologyUrlState`; called with no argument it reads live `window`
|
||||
* state and yields empty state if the URL is unreadable. Callers in render or
|
||||
* effect paths use the no-argument form; tests and any caller that already
|
||||
* holds a search string pass it, which is the only form that is testable.
|
||||
*/
|
||||
export function readOntologyUrlState(search?: string): OntologyUrlState {
|
||||
if (search !== undefined) {
|
||||
return parseOntologyUrlState(search);
|
||||
}
|
||||
try {
|
||||
return parseOntologyUrlState(window.location.search);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the URL addresses the Ontology Hub at all, even with blank parameter values. */
|
||||
export function hasOntologyUrlState(search?: string): boolean {
|
||||
const { tab, entityUri } = readOntologyUrlState(search);
|
||||
return tab !== undefined || entityUri !== undefined;
|
||||
}
|
||||
|
||||
// The transform returns a query string only, so the fragment has to be carried
|
||||
// across explicitly: replaceState with a bare "?..." drops it. This is the one
|
||||
// place that knows how the URL is written, so it is the only place that can.
|
||||
function updateSearch(transform: (search: string) => string): void {
|
||||
try {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${transform(window.location.search)}${window.location.hash}`,
|
||||
);
|
||||
} catch {
|
||||
// Deep-link state is a convenience; every caller stays correct without it.
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTab(tab: string): void {
|
||||
updateSearch((search) => applyTab(search, tab));
|
||||
}
|
||||
|
||||
export function writeEntitySelection(entityUri: string): void {
|
||||
updateSearch((search) => applyEntitySelection(search, entityUri));
|
||||
}
|
||||
|
||||
export function clearEntitySelection(): void {
|
||||
updateSearch(removeEntitySelection);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
compactNodeType,
|
||||
inferOntologyUri,
|
||||
isEditableEntityType,
|
||||
resolveEditorOntology,
|
||||
ONTOLOGY_MINIMAP_THEME,
|
||||
} from "../src/workspaces/OntologyWorkspace/ontologyEditorModel";
|
||||
|
||||
@@ -30,20 +29,6 @@ test("explicit scheme ownership wins when an entity uses another namespace", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("an explicit owner missing from the registry is used, not quietly replaced", () => {
|
||||
// The entity sits under a registered namespace, so the guess has an answer
|
||||
// ready; the backend naming a different, unregistered owner must still win,
|
||||
// or the editor opens an ontology nobody said owned this entity.
|
||||
assert.equal(
|
||||
inferOntologyUri(
|
||||
registry,
|
||||
"https://example.test/foo#Class",
|
||||
"https://unregistered.test/vocab",
|
||||
),
|
||||
"https://unregistered.test/vocab",
|
||||
);
|
||||
});
|
||||
|
||||
test("only draft-supported class and property nodes are editable", () => {
|
||||
assert.equal(isEditableEntityType("class"), true);
|
||||
assert.equal(isEditableEntityType("property"), true);
|
||||
@@ -79,36 +64,3 @@ test("compactNodeType leaves unknown namespaces untouched", () => {
|
||||
assert.equal(compactNodeType("https://example.org/custom#Thing"), "https://example.org/custom#Thing");
|
||||
assert.equal(compactNodeType("owl:Class"), "owl:Class");
|
||||
});
|
||||
|
||||
test("an authoritative no-owner verdict suppresses the namespace guess", () => {
|
||||
// Without suppression the prefix guess would pick the registered parent
|
||||
// for an unregistered nested entity — the deep link must not do that.
|
||||
const nested = "https://example.test/foo/unregistered#Term";
|
||||
assert.deepEqual(resolveEditorOntology(registry, nested, null), {
|
||||
status: "unowned",
|
||||
entityUri: nested,
|
||||
});
|
||||
// An unavailable verdict may still fall back to inference
|
||||
assert.deepEqual(
|
||||
resolveEditorOntology(registry, "https://example.test/foo#Class", undefined),
|
||||
{ status: "resolved", uri: "https://example.test/foo" },
|
||||
);
|
||||
// A named owner wins outright
|
||||
assert.deepEqual(
|
||||
resolveEditorOntology(registry, nested, "https://example.test/foo/nested"),
|
||||
{ status: "resolved", uri: "https://example.test/foo/nested" },
|
||||
);
|
||||
});
|
||||
|
||||
test("unowned is distinguishable from unresolved, so neither collapses to a default", () => {
|
||||
// Both mean "no ontology to open", and the editor treats them oppositely:
|
||||
// unresolved may land on the registry default, unowned must not. A caller
|
||||
// writing `resolve(...) || entries[0]` reintroduced exactly the parent
|
||||
// selection this verdict exists to prevent, so the difference is typed.
|
||||
const unowned = resolveEditorOntology(registry, "https://example.test/foo/x#T", null);
|
||||
const unresolved = resolveEditorOntology(registry, "https://elsewhere.test/T", undefined);
|
||||
|
||||
assert.equal(unowned.status, "unowned");
|
||||
assert.equal(unresolved.status, "unresolved");
|
||||
assert.notEqual(unowned.status, unresolved.status);
|
||||
});
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyEntitySelection,
|
||||
applyTab,
|
||||
clearEntitySelection,
|
||||
hasOntologyUrlState,
|
||||
parseOntologyUrlState,
|
||||
readOntologyUrlState,
|
||||
removeEntitySelection,
|
||||
writeEntitySelection,
|
||||
writeTab,
|
||||
} from "../src/workspaces/OntologyWorkspace/ontologyUrlState";
|
||||
|
||||
function withStubbedLocation(search: string, hash: string, body: () => void): string[] {
|
||||
const written: string[] = [];
|
||||
const original = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
location: { search, hash },
|
||||
history: { replaceState: (_s: unknown, _t: string, url: string) => written.push(url) },
|
||||
};
|
||||
try {
|
||||
body();
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = original;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
test("selecting an entity round-trips and pins the editor tab", () => {
|
||||
const search = applyEntitySelection("", "https://example.test/foo#Bar");
|
||||
assert.deepEqual(parseOntologyUrlState(search), {
|
||||
tab: "editor",
|
||||
entityUri: "https://example.test/foo#Bar",
|
||||
});
|
||||
});
|
||||
|
||||
test("clearing the selection drops only the entity and keeps unrelated params", () => {
|
||||
const search = applyEntitySelection("?view=graph&depth=2", "https://example.test/foo#Bar");
|
||||
const cleared = parseOntologyUrlState(removeEntitySelection(search));
|
||||
|
||||
assert.equal(cleared.entityUri, undefined);
|
||||
assert.equal(cleared.tab, "editor");
|
||||
assert.equal(new URLSearchParams(removeEntitySelection(search)).get("depth"), "2");
|
||||
});
|
||||
|
||||
test("writing a tab leaves an existing entity selection alone", () => {
|
||||
const search = applyTab(applyEntitySelection("", "urn:x"), "health");
|
||||
assert.deepEqual(parseOntologyUrlState(search), { tab: "health", entityUri: "urn:x" });
|
||||
});
|
||||
|
||||
test("absent params read as undefined, blank params as empty strings", () => {
|
||||
assert.deepEqual(parseOntologyUrlState(""), { tab: undefined, entityUri: undefined });
|
||||
assert.deepEqual(parseOntologyUrlState("?other=1"), { tab: undefined, entityUri: undefined });
|
||||
assert.deepEqual(parseOntologyUrlState("?ontologyTab=&ontologyEntity="), {
|
||||
tab: "",
|
||||
entityUri: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("a present but blank param still counts as ontology deep-link state", () => {
|
||||
assert.equal(hasOntologyUrlState("?ontologyEntity="), true);
|
||||
assert.equal(hasOntologyUrlState("?ontologyTab="), true);
|
||||
assert.equal(hasOntologyUrlState("?view=graph"), false);
|
||||
assert.equal(hasOntologyUrlState(""), false);
|
||||
});
|
||||
|
||||
test("malformed search strings degrade to plain values instead of throwing", () => {
|
||||
assert.deepEqual(parseOntologyUrlState("???"), { tab: undefined, entityUri: undefined });
|
||||
assert.deepEqual(parseOntologyUrlState("ontologyEntity=urn%3Ax&&=&"), {
|
||||
tab: undefined,
|
||||
entityUri: "urn:x",
|
||||
});
|
||||
});
|
||||
|
||||
test("entity URIs survive characters that need escaping", () => {
|
||||
const entityUri = "https://example.test/vocab#Has Part/&?=";
|
||||
const search = applyEntitySelection("?keep=1", entityUri);
|
||||
assert.equal(parseOntologyUrlState(search).entityUri, entityUri);
|
||||
});
|
||||
|
||||
test("every writer preserves the URL fragment", () => {
|
||||
const written = withStubbedLocation("?view=graph", "#section-3", () => {
|
||||
writeTab("health");
|
||||
writeEntitySelection("urn:x");
|
||||
clearEntitySelection();
|
||||
});
|
||||
|
||||
assert.deepEqual(written, [
|
||||
"?view=graph&ontologyTab=health#section-3",
|
||||
"?view=graph&ontologyTab=editor&ontologyEntity=urn%3Ax#section-3",
|
||||
"?view=graph#section-3",
|
||||
]);
|
||||
});
|
||||
|
||||
test("readOntologyUrlState with no argument reads live URL state", () => {
|
||||
withStubbedLocation("?ontologyTab=editor&ontologyEntity=urn%3Ax", "", () => {
|
||||
assert.deepEqual(readOntologyUrlState(), { tab: "editor", entityUri: "urn:x" });
|
||||
assert.equal(hasOntologyUrlState(), true);
|
||||
});
|
||||
});
|
||||
@@ -244,7 +244,6 @@ class EntityDetailResponse(BaseModel):
|
||||
entity_type: str
|
||||
definition: Optional[str] = None
|
||||
source_ontology: Optional[str] = None
|
||||
owning_ontology: Optional[str] = None
|
||||
superclasses: List[str] = Field(default_factory=list)
|
||||
subclasses: List[str] = Field(default_factory=list)
|
||||
domain: List[str] = Field(default_factory=list)
|
||||
@@ -815,55 +814,6 @@ def _node_belongs_to_ontology(
|
||||
return "#" not in local_name and "/" not in local_name
|
||||
|
||||
|
||||
def _resolve_owning_ontology(
|
||||
node: Dict[str, Any],
|
||||
known_ontology_uris: set[str],
|
||||
) -> Optional[str]:
|
||||
"""Return the one known ontology that owns this node, or None if none does.
|
||||
|
||||
The most specific (longest) match wins, so a nested vocabulary claims its
|
||||
own terms instead of the parent absorbing them.
|
||||
|
||||
Kept agreeing with _node_belongs_to_ontology by construction — the same
|
||||
three rules in the same order — but in one pass over the candidates rather
|
||||
than one pass per candidate, each of which rescanned the whole set to find
|
||||
the longest namespace. That made resolution quadratic in the number of
|
||||
registered ontologies.
|
||||
"""
|
||||
nid = str(node.get("id", ""))
|
||||
if not nid:
|
||||
return None
|
||||
|
||||
# An ontology node owns itself, ahead of any scheme_uri it may carry.
|
||||
if nid in known_ontology_uris:
|
||||
return nid
|
||||
|
||||
# An explicit owner is authoritative even when it is not registered:
|
||||
# naming a different ontology by namespace guess would be worse than
|
||||
# reporting the one the node itself points at.
|
||||
explicit_owner = _node_source_ontology(node)
|
||||
if explicit_owner:
|
||||
return explicit_owner
|
||||
|
||||
longest_namespace: Optional[str] = None
|
||||
for candidate in known_ontology_uris:
|
||||
stem = candidate.rstrip("#/")
|
||||
if not nid.startswith((stem + "#", stem + "/")):
|
||||
continue
|
||||
if longest_namespace is None or len(candidate) > len(longest_namespace):
|
||||
longest_namespace = candidate
|
||||
if longest_namespace is None:
|
||||
return None
|
||||
|
||||
# Prefix ownership only extends to names minted directly in the namespace.
|
||||
# A further delimiter marks a nested vocabulary, which stays unowned until
|
||||
# it is registered or carries an explicit owner.
|
||||
local_name = nid[len(longest_namespace.rstrip("#/")) + 1 :]
|
||||
if "#" in local_name or "/" in local_name:
|
||||
return None
|
||||
return longest_namespace
|
||||
|
||||
|
||||
def _is_ontology_entity(node: Dict[str, Any]) -> bool:
|
||||
return _classify_node_type(node.get("type", "")) in {"class", "property", "concept", "scheme"}
|
||||
|
||||
@@ -2002,7 +1952,6 @@ async def get_ontology_graph(
|
||||
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
|
||||
async def get_entity_detail(
|
||||
entity_uri: str,
|
||||
request: Request,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, entity_uri)
|
||||
@@ -2024,21 +1973,12 @@ async def get_entity_detail(
|
||||
|
||||
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
instance_count = sum(1 for n in all_nodes if n.get("type") == entity_uri)
|
||||
# Ownership must use the same candidate set as /graph, so it goes through the
|
||||
# same helper rather than being derived from all_nodes above: that scan is
|
||||
# capped at 999,999, and on a larger graph a truncated set would silently
|
||||
# drop ontologies and make the two endpoints disagree about who owns a node.
|
||||
# The helper iterates only the ontology node types, so it is not a full scan.
|
||||
known_ontology_uris = await asyncio.to_thread(
|
||||
_known_ontology_uris, session, _get_registry(request)
|
||||
)
|
||||
|
||||
return EntityDetailResponse(
|
||||
uri=entity_uri, label=label,
|
||||
type=ntype, entity_type=_classify_node_type(ntype),
|
||||
definition=definition,
|
||||
source_ontology=props.get("scheme_uri"),
|
||||
owning_ontology=_resolve_owning_ontology(node, known_ontology_uris),
|
||||
superclasses=superclasses, subclasses=subclasses,
|
||||
domain=domain, range=range_,
|
||||
instance_count=instance_count, properties=props,
|
||||
|
||||
@@ -17,7 +17,6 @@ from semantica.explorer.routes.ontology import ( # noqa: E402
|
||||
OntologyEntry,
|
||||
_convert_ontology_to_graph,
|
||||
_node_belongs_to_ontology,
|
||||
_resolve_owning_ontology,
|
||||
)
|
||||
from semantica.explorer.session import GraphSession # noqa: E402
|
||||
|
||||
@@ -260,89 +259,6 @@ def test_node_belongs_to_ontology_nested_namespace_matrix():
|
||||
assert _node_belongs_to_ontology(node(f"{child}/Term"), child, {parent, child})
|
||||
|
||||
|
||||
def test_entity_detail_reports_explicit_owner(client):
|
||||
response = client.get(
|
||||
f"/api/ontology/entity/{quote('http://example.org/onto-a#Person', safe='')}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["source_ontology"] == "http://example.org/onto-a"
|
||||
assert payload["owning_ontology"] == "http://example.org/onto-a"
|
||||
|
||||
|
||||
def test_entity_detail_reports_namespace_owner_without_explicit_scheme(client):
|
||||
graph = client.app.state.session.graph
|
||||
minted_directly = "http://example.org/onto-a#Address"
|
||||
graph.add_node(minted_directly, node_type="owl:Class", content="Address")
|
||||
|
||||
response = client.get(f"/api/ontology/entity/{quote(minted_directly, safe='')}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["owning_ontology"] == "http://example.org/onto-a"
|
||||
|
||||
|
||||
def test_entity_detail_reports_no_owner_for_unregistered_nested_namespace(client):
|
||||
graph = client.app.state.session.graph
|
||||
nested_term = "http://example.org/onto-a/nested#Term"
|
||||
graph.add_node(nested_term, node_type="owl:Class", content="Nested Term")
|
||||
|
||||
response = client.get(f"/api/ontology/entity/{quote(nested_term, safe='')}")
|
||||
|
||||
assert response.status_code == 200
|
||||
# onto-a must not claim a nested vocabulary its own /graph response
|
||||
# excludes, or a deep link selects onto-a and then finds nothing to select.
|
||||
assert response.json()["owning_ontology"] is None
|
||||
|
||||
|
||||
def test_entity_detail_reports_an_explicit_owner_outside_the_registry(client):
|
||||
graph = client.app.state.session.graph
|
||||
borrowed = "http://example.org/onto-a#Borrowed"
|
||||
unregistered_owner = "http://unregistered.example/vocab"
|
||||
# Sits directly in onto-a's namespace, so the namespace rule has an answer
|
||||
# ready — the node's own scheme_uri still has to win, or /entity reports an
|
||||
# owner that contradicts the node and the editor opens the wrong ontology.
|
||||
graph.add_node(
|
||||
borrowed,
|
||||
node_type="owl:Class",
|
||||
content="Borrowed",
|
||||
scheme_uri=unregistered_owner,
|
||||
)
|
||||
|
||||
response = client.get(f"/api/ontology/entity/{quote(borrowed, safe='')}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["owning_ontology"] == unregistered_owner
|
||||
|
||||
|
||||
def test_owner_resolution_agrees_with_graph_membership(client):
|
||||
"""_resolve_owning_ontology and _node_belongs_to_ontology must not diverge.
|
||||
|
||||
The two answer the same question from opposite directions, and /entity and
|
||||
/graph each use one of them. If they disagree, a deep link opens an ontology
|
||||
whose graph then excludes the entity it was opened for.
|
||||
"""
|
||||
parent = "http://example.org/onto-a"
|
||||
nested = "http://example.org/onto-a/nested"
|
||||
known = {parent, nested}
|
||||
cases = [
|
||||
{"id": parent},
|
||||
{"id": f"{parent}#Direct"},
|
||||
{"id": f"{parent}/Direct"},
|
||||
{"id": f"{nested}#Term"},
|
||||
{"id": f"{parent}/unregistered#Term"},
|
||||
{"id": "http://elsewhere.example/Thing"},
|
||||
{"id": f"{parent}#Explicit", "properties": {"scheme_uri": nested}},
|
||||
]
|
||||
|
||||
for node in cases:
|
||||
owner = _resolve_owning_ontology(node, known)
|
||||
for candidate in known:
|
||||
assert _node_belongs_to_ontology(node, candidate, known) == (
|
||||
owner == candidate
|
||||
), f"{node['id']} vs {candidate}: owner={owner}"
|
||||
|
||||
|
||||
def test_load_fallback_import_without_declaration_is_editable(client):
|
||||
turtle = """
|
||||
@prefix ex: <http://data.example.org/people#> .
|
||||
|
||||
Reference in New Issue
Block a user