mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 13:17:07 +00:00
feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Everything the two shipped PRD phases deferred, minus what is called out below.
Storage (Phase 2)
@logicsrc/openontology gains a SQLite/Turso adapter. It hydrates the read
model at open, serves reads synchronously — a query evaluator that awaits per
triple pattern is unusable — and buffers mutations as SQL that flush() writes
in one transaction. Versioned idempotent migrations; indexes over subject,
predicate, entity-valued object, status, both time axes, aliases, and external
ids; FTS5 for label/alias search. The append-only status log is replayed on
open, so retractions, supersessions, and merge redirects survive a reopen.
REST + SSE + OpenAPI (Phase 2)
16 paths under /api/ontologies in logicsrc-web, described at
/api/ontologies/openapi and referencing the published JSON Schemas rather
than restating them. No token is read-only; a curator token can apply; an
agent token can propose and cannot apply. Idempotency-Key on mutations,
revision ETags, 409 on a stale base revision, and an SSE stream that emits
the same event objects as the JSON endpoint.
MCP (Phase 2)
OpenOntology and OpenPRD surfaces on the standards server: spec/manifest/
schema/queries and PRD spec/index as resources, 11 ontology tools and 6 PRD
tools, 7 prompts. Read-only by default; OPENONTOLOGY_MCP_WRITABLE=1 buys
proposals, never applies — the denial is the shared policy layer, not a
second rule that could drift.
Interoperability (Phase 3)
RDF/Turtle export and import of the reified profile, plus the plain triple
for asserted relationships so a consumer wanting only the accepted graph gets
one. SHACL for 5 of 7 constraint kinds; `unique` and `query` are reported as
unmapped in both the return value and the generated Turtle, because a shape
that quietly means something narrower is worse than no shape.
Source adapters (Phase 3)
CSV, JSON, YAML, NDJSON, Markdown, generic JSON HTTP, and GitHub. All produce
PROPOSED change-set operations with source, evidence selector, run id, and
confidence attached; fetch is injected so ingestion is offline and testable.
Each declares its capabilities, so "nothing was deleted upstream" is never
confused with "this adapter cannot see deletions" — none of the seven can.
TUI + explorer
Keyboard-first panels (types, entities, claims, sources, queries, change
sets, validation, audit) as plain strings that survive SSH and 60 columns;
status is a glyph and a word, never colour alone; the key bar wraps rather
than truncating. Wired as `logicsrc ontology tui`. A read-only web explorer
at /openontology/explore with entity and claim views showing status, both
clocks, confidence, sources, evidence, and append-only history — plus an
/openprd page for the companion standard.
Bugs found and fixed while testing
- the API built a new engine per request, so `explain` could never find a
resultId from a prior request; engines are now cached per role
- the TUI status bar called engine.validateOntologyPackage(), appending a
package.validated event on every repaint; it now uses the pure validator
Verification: 76 new tests (527 total across the monorepo, all passing); full
build green; the libSQL adapter is exercised against real files, the API
through its route handlers, and MCP over an in-memory transport.
Not included: PWA review/approval write flows (they need an auth story this
deployment does not have), OWL/RDFS mappings, SPARQL/Cypher/Datalog query
adapters, and Phase 4 governed actions. The compatibility matrix marks those
"planned", not "supported".
Refs: prd/0001-add-logicsrc-openontology-spec.md
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
296775e003
commit
da5f6f8381
53 changed files with 6939 additions and 23 deletions
209
packages/openontology/src/shacl.ts
Normal file
209
packages/openontology/src/shacl.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { packagePrefix, OO } from "./jsonld.js";
|
||||
import type { BuiltPackage, Constraint, LoadedPackage } from "./types.js";
|
||||
|
||||
/**
|
||||
* SHACL mapping for the constraint kinds whose semantics genuinely match.
|
||||
*
|
||||
* Four of the seven map cleanly onto node/property shapes. `unique` and the
|
||||
* query-based checks do not — SHACL has no portable "this value appears once
|
||||
* across the graph" without SPARQL constraints, and an OpenOntology saved
|
||||
* query is not a SPARQL query. Those are reported as unmapped rather than
|
||||
* approximated, because a shape that silently means something else is worse
|
||||
* than no shape at all.
|
||||
*/
|
||||
|
||||
export interface ShaclExport {
|
||||
turtle: string;
|
||||
mapped: Array<{ constraint: string; shape: string }>;
|
||||
unmapped: Array<{ constraint: string; rule: string; reason: string }>;
|
||||
}
|
||||
|
||||
export function constraintsToShacl(pkg: BuiltPackage | LoadedPackage): ShaclExport {
|
||||
const manifest = pkg.manifest;
|
||||
const ns = manifest.namespace.endsWith("/") ? manifest.namespace : `${manifest.namespace}/`;
|
||||
const prefix = packagePrefix(pkg);
|
||||
|
||||
const mapped: ShaclExport["mapped"] = [];
|
||||
const unmapped: ShaclExport["unmapped"] = [];
|
||||
const body: string[] = [];
|
||||
|
||||
const shapeName = (constraint: Constraint) =>
|
||||
`ns:${toPascal(constraint.id)}Shape`;
|
||||
|
||||
for (const constraint of pkg.schema.constraints) {
|
||||
const severity = shaclSeverity(constraint.severity ?? "error");
|
||||
const shape = shapeName(constraint);
|
||||
const rule = constraint.rule;
|
||||
|
||||
switch (rule.type) {
|
||||
case "required-predicate": {
|
||||
body.push(
|
||||
`${shape}`,
|
||||
` a sh:NodeShape ;`,
|
||||
` sh:targetClass ns:${rule.entityType} ;`,
|
||||
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
|
||||
` sh:property [`,
|
||||
` sh:path ns:${rule.predicate} ;`,
|
||||
` sh:minCount 1 ;`,
|
||||
` sh:severity ${severity} ;`,
|
||||
` sh:message ${JSON.stringify(constraint.description)} ;`,
|
||||
` ] .`,
|
||||
""
|
||||
);
|
||||
mapped.push({ constraint: constraint.id, shape });
|
||||
break;
|
||||
}
|
||||
|
||||
case "cardinality": {
|
||||
const counts = [
|
||||
rule.min !== undefined ? ` sh:minCount ${rule.min} ;` : null,
|
||||
rule.max !== undefined ? ` sh:maxCount ${rule.max} ;` : null
|
||||
].filter(Boolean) as string[];
|
||||
body.push(
|
||||
`${shape}`,
|
||||
` a sh:NodeShape ;`,
|
||||
rule.entityType ? ` sh:targetClass ns:${rule.entityType} ;` : ` sh:targetSubjectsOf ns:${rule.predicate} ;`,
|
||||
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
|
||||
` sh:property [`,
|
||||
` sh:path ns:${rule.predicate} ;`,
|
||||
...counts,
|
||||
` sh:severity ${severity} ;`,
|
||||
` sh:message ${JSON.stringify(constraint.description)} ;`,
|
||||
` ] .`,
|
||||
""
|
||||
);
|
||||
mapped.push({ constraint: constraint.id, shape });
|
||||
break;
|
||||
}
|
||||
|
||||
case "allowed-values": {
|
||||
body.push(
|
||||
`${shape}`,
|
||||
` a sh:NodeShape ;`,
|
||||
` sh:targetSubjectsOf ns:${rule.predicate} ;`,
|
||||
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
|
||||
` sh:property [`,
|
||||
` sh:path ns:${rule.predicate} ;`,
|
||||
` sh:in (${rule.values.map((value) => JSON.stringify(String(value))).join(" ")}) ;`,
|
||||
` sh:severity ${severity} ;`,
|
||||
` sh:message ${JSON.stringify(constraint.description)} ;`,
|
||||
` ] .`,
|
||||
""
|
||||
);
|
||||
mapped.push({ constraint: constraint.id, shape });
|
||||
break;
|
||||
}
|
||||
|
||||
case "domain-range": {
|
||||
const lines = [`${shape}`, ` a sh:NodeShape ;`];
|
||||
lines.push(` sh:targetSubjectsOf ns:${rule.predicate} ;`);
|
||||
lines.push(` rdfs:comment ${JSON.stringify(constraint.description)} ;`);
|
||||
if (rule.from?.length) {
|
||||
lines.push(
|
||||
` sh:or (${rule.from.map((type) => `[ sh:class ns:${type} ]`).join(" ")}) ;`
|
||||
);
|
||||
}
|
||||
if (rule.to?.length) {
|
||||
lines.push(
|
||||
` sh:property [`,
|
||||
` sh:path ns:${rule.predicate} ;`,
|
||||
` sh:or (${rule.to.map((type) => `[ sh:class ns:${type} ]`).join(" ")}) ;`,
|
||||
` sh:severity ${severity} ;`,
|
||||
` ] ;`
|
||||
);
|
||||
}
|
||||
lines.push(` sh:severity ${severity} .`, "");
|
||||
body.push(...lines);
|
||||
mapped.push({ constraint: constraint.id, shape });
|
||||
break;
|
||||
}
|
||||
|
||||
case "temporal-bounds": {
|
||||
const props: string[] = [];
|
||||
if (rule.notBefore) props.push(` sh:minInclusive ${JSON.stringify(rule.notBefore)} ;`);
|
||||
if (rule.notAfter) props.push(` sh:maxInclusive ${JSON.stringify(rule.notAfter)} ;`);
|
||||
if (rule.requireValidFrom) props.push(` sh:minCount 1 ;`);
|
||||
if (props.length === 0) {
|
||||
unmapped.push({
|
||||
constraint: constraint.id,
|
||||
rule: rule.type,
|
||||
reason: "temporal-bounds with no bounds has nothing to express"
|
||||
});
|
||||
break;
|
||||
}
|
||||
body.push(
|
||||
`${shape}`,
|
||||
` a sh:NodeShape ;`,
|
||||
` sh:targetObjectsOf ns:${rule.predicate} ;`,
|
||||
` rdfs:comment ${JSON.stringify(constraint.description)} ;`,
|
||||
` sh:property [`,
|
||||
` sh:path oo:validFrom ;`,
|
||||
...props,
|
||||
` sh:severity ${severity} ;`,
|
||||
` ] .`,
|
||||
""
|
||||
);
|
||||
mapped.push({ constraint: constraint.id, shape });
|
||||
break;
|
||||
}
|
||||
|
||||
case "unique":
|
||||
unmapped.push({
|
||||
constraint: constraint.id,
|
||||
rule: rule.type,
|
||||
reason:
|
||||
"graph-wide uniqueness has no portable SHACL Core equivalent; it needs a sh:SPARQLConstraint"
|
||||
});
|
||||
break;
|
||||
|
||||
case "query":
|
||||
unmapped.push({
|
||||
constraint: constraint.id,
|
||||
rule: rule.type,
|
||||
reason: "an OpenOntology saved query is a triple-pattern AST, not SPARQL"
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
unmapped.push({ constraint: (constraint as Constraint).id, rule: "unknown", reason: "unrecognized rule type" });
|
||||
}
|
||||
}
|
||||
|
||||
const header = [
|
||||
"@prefix sh: <http://www.w3.org/ns/shacl#> .",
|
||||
"@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .",
|
||||
"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .",
|
||||
`@prefix oo: <${OO}> .`,
|
||||
`@prefix ns: <${ns}> .`,
|
||||
"",
|
||||
`# SHACL shapes generated from ${manifest.id}@${manifest.version} (compact prefix: ${prefix})`,
|
||||
`# ${mapped.length} constraint(s) mapped, ${unmapped.length} not mappable to SHACL Core.`,
|
||||
...unmapped.map((entry) => `# unmapped: ${entry.constraint} (${entry.rule}) — ${entry.reason}`),
|
||||
""
|
||||
];
|
||||
|
||||
return {
|
||||
turtle: `${[...header, ...body].join("\n").trimEnd()}\n`,
|
||||
mapped,
|
||||
unmapped
|
||||
};
|
||||
}
|
||||
|
||||
function shaclSeverity(severity: string): string {
|
||||
switch (severity) {
|
||||
case "warning":
|
||||
return "sh:Warning";
|
||||
case "info":
|
||||
case "policy":
|
||||
return "sh:Info";
|
||||
default:
|
||||
return "sh:Violation";
|
||||
}
|
||||
}
|
||||
|
||||
function toPascal(id: string): string {
|
||||
return id
|
||||
.split(/[-_\s]+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue