mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
Normalize term translation with Effect Match
This commit is contained in:
parent
e311315556
commit
09d34fb4d4
11 changed files with 349 additions and 190 deletions
|
|
@ -4,6 +4,12 @@ import {
|
|||
dispatcherManagerIsCompleteResponse,
|
||||
makeDispatcherManager,
|
||||
} from "../gateway/dispatch/manager.js";
|
||||
import {
|
||||
clientTermToInternal,
|
||||
internalTermToClient,
|
||||
translateRequestEffect,
|
||||
translateResponseEffect,
|
||||
} from "../gateway/dispatch/serialize.js";
|
||||
import type {
|
||||
BackendConsumer,
|
||||
BackendProducer,
|
||||
|
|
@ -153,6 +159,69 @@ class DispatchBackend implements PubSubBackend {
|
|||
}
|
||||
|
||||
describe("gateway dispatcher manager", () => {
|
||||
it("translates compact client terms with Match and schema-backed narrowing", async () => {
|
||||
const internal = clientTermToInternal({
|
||||
t: "t",
|
||||
tr: {
|
||||
s: { t: "i", i: "urn:s" },
|
||||
p: { t: "b", d: "blank" },
|
||||
o: { t: "l", v: "value", dt: "urn:datatype", ln: "en" },
|
||||
g: "urn:graph",
|
||||
},
|
||||
});
|
||||
|
||||
expect(internal).toEqual({
|
||||
type: "TRIPLE",
|
||||
triple: {
|
||||
s: { type: "IRI", iri: "urn:s" },
|
||||
p: { type: "BLANK", id: "blank" },
|
||||
o: {
|
||||
type: "LITERAL",
|
||||
value: "value",
|
||||
datatype: "urn:datatype",
|
||||
language: "en",
|
||||
},
|
||||
g: "urn:graph",
|
||||
},
|
||||
});
|
||||
|
||||
expect(internalTermToClient(internal)).toEqual({
|
||||
t: "t",
|
||||
tr: {
|
||||
s: { t: "i", i: "urn:s" },
|
||||
p: { t: "b", d: "blank" },
|
||||
o: { t: "l", v: "value", dt: "urn:datatype", ln: "en" },
|
||||
g: "urn:graph",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("deep-translates only schema-valid term-shaped values", async () => {
|
||||
const request = await Effect.runPromise(
|
||||
translateRequestEffect("knowledge", {
|
||||
term: { t: "i", i: "urn:item" },
|
||||
malformedKnownTag: { t: "i" },
|
||||
untouched: { t: "unknown", value: "kept" },
|
||||
}),
|
||||
);
|
||||
const response = await Effect.runPromise(
|
||||
translateResponseEffect("knowledge", {
|
||||
term: { type: "IRI", iri: "urn:item" },
|
||||
untouched: { type: "unknown", value: "kept" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(request).toEqual({
|
||||
term: { type: "IRI", iri: "urn:item" },
|
||||
malformedKnownTag: { t: "i" },
|
||||
untouched: { t: "unknown", value: "kept" },
|
||||
});
|
||||
expect(response).toEqual({
|
||||
term: { t: "i", i: "urn:item" },
|
||||
untouched: { type: "unknown", value: "kept" },
|
||||
});
|
||||
});
|
||||
|
||||
it("caches Effect requestors as scoped handles", async () => {
|
||||
const backend = new DispatchBackend();
|
||||
const manager = makeDispatcherManager({
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import type {
|
|||
Triple,
|
||||
} from "@trustgraph/base";
|
||||
import {Term as TermSchema} from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import { Effect, Match } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as Predicate from "effect/Predicate";
|
||||
import * as S from "effect/Schema";
|
||||
|
|
@ -33,16 +33,15 @@ const decodeTerm = S.decodeUnknownOption(TermSchema);
|
|||
* Format a Term to a human-readable string.
|
||||
*/
|
||||
function termToString(term: Term): string {
|
||||
switch (term.type) {
|
||||
case "IRI":
|
||||
return term.iri;
|
||||
case "LITERAL":
|
||||
return term.value;
|
||||
case "BLANK":
|
||||
return `_:${term.id}`;
|
||||
case "TRIPLE":
|
||||
return `(${termToString(term.triple.s)} ${termToString(term.triple.p)} ${termToString(term.triple.o)})`;
|
||||
}
|
||||
return Match.type<Term>().pipe(
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
IRI: (iri) => iri.iri,
|
||||
LITERAL: (literal) => literal.value,
|
||||
BLANK: (blank) => `_:${blank.id}`,
|
||||
TRIPLE: (triple) =>
|
||||
`(${termToString(triple.triple.s)} ${termToString(triple.triple.p)} ${termToString(triple.triple.o)})`,
|
||||
}),
|
||||
)(term);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,8 +18,19 @@
|
|||
* Python reference: trustgraph-base/trustgraph/messaging/translators/primitives.py
|
||||
*/
|
||||
|
||||
import { errorMessage, type Term, type Triple } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
BlankTerm,
|
||||
errorMessage,
|
||||
IriTerm,
|
||||
LiteralTerm,
|
||||
Term as TermSchema,
|
||||
Triple as TripleSchema,
|
||||
TripleTerm,
|
||||
type Term,
|
||||
type Triple,
|
||||
} from "@trustgraph/base";
|
||||
import { Effect, Match } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
// ---------- Client wire format type definitions ----------
|
||||
|
|
@ -65,77 +76,102 @@ interface ClientTriple {
|
|||
g?: string;
|
||||
}
|
||||
|
||||
// ---------- Client → Internal ----------
|
||||
const ClientIriTermSchema = S.Struct({
|
||||
t: S.tag("i"),
|
||||
i: S.String,
|
||||
});
|
||||
|
||||
export function clientTermToInternal(wire: ClientTerm): Term {
|
||||
switch (wire.t) {
|
||||
case "i":
|
||||
return { type: "IRI", iri: wire.i };
|
||||
case "b":
|
||||
return { type: "BLANK", id: wire.d };
|
||||
case "l":
|
||||
return {
|
||||
type: "LITERAL",
|
||||
value: wire.v,
|
||||
...(wire.dt !== undefined ? { datatype: wire.dt } : {}),
|
||||
...(wire.ln !== undefined ? { language: wire.ln } : {}),
|
||||
};
|
||||
case "t": {
|
||||
const ClientBlankTermSchema = S.Struct({
|
||||
t: S.tag("b"),
|
||||
d: S.String,
|
||||
});
|
||||
|
||||
const ClientLiteralTermSchema = S.Struct({
|
||||
t: S.tag("l"),
|
||||
v: S.String,
|
||||
dt: S.optionalKey(S.String),
|
||||
ln: S.optionalKey(S.String),
|
||||
});
|
||||
|
||||
const ClientTripleSchema: S.Codec<ClientTriple, ClientTriple> = S.Struct({
|
||||
s: S.suspend((): S.Codec<ClientTerm, ClientTerm> => ClientTermSchema),
|
||||
p: S.suspend((): S.Codec<ClientTerm, ClientTerm> => ClientTermSchema),
|
||||
o: S.suspend((): S.Codec<ClientTerm, ClientTerm> => ClientTermSchema),
|
||||
g: S.optionalKey(S.String),
|
||||
});
|
||||
|
||||
const ClientTripleTermSchema = S.Struct({
|
||||
t: S.tag("t"),
|
||||
tr: S.optionalKey(ClientTripleSchema),
|
||||
});
|
||||
|
||||
const ClientTermSchema = S.Union([
|
||||
ClientIriTermSchema,
|
||||
ClientBlankTermSchema,
|
||||
ClientLiteralTermSchema,
|
||||
ClientTripleTermSchema,
|
||||
]).pipe(S.toTaggedUnion("t"));
|
||||
|
||||
const decodeClientTerm = S.decodeUnknownOption(ClientTermSchema);
|
||||
const decodeInternalTerm = S.decodeUnknownOption(TermSchema);
|
||||
|
||||
const clientTermToInternalMatch = Match.type<ClientTerm>().pipe(
|
||||
Match.discriminatorsExhaustive("t")({
|
||||
i: (wire) => IriTerm.make({ iri: wire.i }),
|
||||
b: (wire) => BlankTerm.make({ id: wire.d }),
|
||||
l: (wire) => LiteralTerm.make({
|
||||
value: wire.v,
|
||||
...(wire.dt !== undefined ? { datatype: wire.dt } : {}),
|
||||
...(wire.ln !== undefined ? { language: wire.ln } : {}),
|
||||
}),
|
||||
t: (wire) => {
|
||||
if (wire.tr === undefined) {
|
||||
throw DispatchSerializationError.make({
|
||||
operation: "client-term-to-internal",
|
||||
message: "Client triple term is missing tr",
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: "TRIPLE",
|
||||
return TripleTerm.make({
|
||||
triple: clientTripleToInternal(wire.tr),
|
||||
};
|
||||
}
|
||||
default:
|
||||
// Defensive: pass through unknown term types
|
||||
return wire as unknown as Term;
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const internalTermToClientMatch = Match.type<Term>().pipe(
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
IRI: (term) => ClientIriTermSchema.make({ i: term.iri }),
|
||||
BLANK: (term) => ClientBlankTermSchema.make({ d: term.id }),
|
||||
LITERAL: (term) => ClientLiteralTermSchema.make({
|
||||
v: term.value,
|
||||
...(term.datatype !== undefined ? { dt: term.datatype } : {}),
|
||||
...(term.language !== undefined ? { ln: term.language } : {}),
|
||||
}),
|
||||
TRIPLE: (term) => ClientTripleTermSchema.make({
|
||||
tr: internalTripleToClient(term.triple),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------- Client → Internal ----------
|
||||
|
||||
export function clientTermToInternal(wire: ClientTerm): Term {
|
||||
return clientTermToInternalMatch(wire);
|
||||
}
|
||||
|
||||
export function clientTripleToInternal(wire: ClientTriple): Triple {
|
||||
const result: Triple = {
|
||||
return TripleSchema.make({
|
||||
s: clientTermToInternal(wire.s),
|
||||
p: clientTermToInternal(wire.p),
|
||||
o: clientTermToInternal(wire.o),
|
||||
};
|
||||
if (wire.g !== undefined) {
|
||||
// In the client wire format, g is a plain string.
|
||||
// In the internal format, g is an optional Term (named graph).
|
||||
// The Python translator treats g as a plain string passthrough,
|
||||
// so we keep it as-is for compatibility.
|
||||
(result as unknown as Record<string, unknown>).g = wire.g;
|
||||
}
|
||||
return result;
|
||||
...(wire.g !== undefined ? { g: wire.g } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Internal → Client ----------
|
||||
|
||||
export function internalTermToClient(term: Term): ClientTerm {
|
||||
switch (term.type) {
|
||||
case "IRI":
|
||||
return { t: "i", i: term.iri };
|
||||
case "BLANK":
|
||||
return { t: "b", d: term.id };
|
||||
case "LITERAL": {
|
||||
const lit: ClientLiteralTerm = { t: "l", v: term.value };
|
||||
if (term.datatype !== undefined) lit.dt = term.datatype;
|
||||
if (term.language !== undefined) lit.ln = term.language;
|
||||
return lit;
|
||||
}
|
||||
case "TRIPLE":
|
||||
return {
|
||||
t: "t",
|
||||
tr: internalTripleToClient(term.triple),
|
||||
};
|
||||
default:
|
||||
return term as unknown as ClientTerm;
|
||||
}
|
||||
return internalTermToClientMatch(term);
|
||||
}
|
||||
|
||||
export function internalTripleToClient(triple: Triple): ClientTriple {
|
||||
|
|
@ -144,17 +180,8 @@ export function internalTripleToClient(triple: Triple): ClientTriple {
|
|||
p: internalTermToClient(triple.p),
|
||||
o: internalTermToClient(triple.o),
|
||||
};
|
||||
const g = (triple as unknown as Record<string, unknown>).g;
|
||||
if (g !== undefined && g !== null) {
|
||||
if (typeof g === "string") {
|
||||
result.g = g;
|
||||
} else {
|
||||
// If g is a Term, convert it back to client wire format
|
||||
const iri = (g as Record<string, unknown>).iri;
|
||||
if (typeof iri === "string") {
|
||||
result.g = iri;
|
||||
}
|
||||
}
|
||||
if (triple.g !== undefined) {
|
||||
result.g = triple.g;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -166,32 +193,6 @@ export function internalTripleToClient(triple: Triple): ClientTriple {
|
|||
* A client term is detected by the presence of a `t` property
|
||||
* with value "i", "b", "l", or "t".
|
||||
*/
|
||||
function isClientTerm(v: unknown): v is ClientTerm {
|
||||
return (
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
"t" in v &&
|
||||
typeof (v as Record<string, unknown>).t === "string" &&
|
||||
["i", "b", "l", "t"].includes((v as Record<string, unknown>).t as string)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An internal term is detected by the presence of a `type` property
|
||||
* with value "IRI", "BLANK", "LITERAL", or "TRIPLE".
|
||||
*/
|
||||
function isInternalTerm(v: unknown): v is Term {
|
||||
return (
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
"type" in v &&
|
||||
typeof (v as Record<string, unknown>).type === "string" &&
|
||||
["IRI", "BLANK", "LITERAL", "TRIPLE"].includes(
|
||||
(v as Record<string, unknown>).type as string,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-translate all client Terms in a request body to internal format.
|
||||
* Handles nested objects and arrays.
|
||||
|
|
@ -204,11 +205,12 @@ function deepClientToInternal(value: unknown): unknown {
|
|||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
if (isClientTerm(value)) {
|
||||
return clientTermToInternal(value);
|
||||
const term = decodeClientTerm(value);
|
||||
if (O.isSome(term)) {
|
||||
return clientTermToInternal(term.value);
|
||||
}
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
result[k] = deepClientToInternal(v);
|
||||
}
|
||||
return result;
|
||||
|
|
@ -229,11 +231,12 @@ function deepInternalToClient(value: unknown): unknown {
|
|||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
if (isInternalTerm(value)) {
|
||||
return internalTermToClient(value);
|
||||
const term = decodeInternalTerm(value);
|
||||
if (O.isSome(term)) {
|
||||
return internalTermToClient(term.value);
|
||||
}
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
result[k] = deepInternalToClient(v);
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import { createClient, Graph } from "falkordb";
|
||||
import { errorMessage, type Term, type Triple } from "@trustgraph/base";
|
||||
import { Config, Context, Effect, Layer } from "effect";
|
||||
import { Config, Context, Effect, Layer, Match } from "effect";
|
||||
import * as Predicate from "effect/Predicate";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
|
|
@ -41,16 +41,14 @@ export interface FalkorDBQueryConfig {
|
|||
|
||||
function termToValue(term: Term | undefined): string | null {
|
||||
if (term === undefined) return null;
|
||||
switch (term.type) {
|
||||
case "IRI":
|
||||
return term.iri;
|
||||
case "LITERAL":
|
||||
return term.value;
|
||||
case "BLANK":
|
||||
return term.id;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return Match.type<Term>().pipe(
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
IRI: (iri) => iri.iri,
|
||||
LITERAL: (literal) => literal.value,
|
||||
BLANK: (blank) => blank.id,
|
||||
TRIPLE: () => null,
|
||||
}),
|
||||
)(term);
|
||||
}
|
||||
|
||||
function createTerm(value: string): Term {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import type {
|
|||
TriplesQueryResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import { Context, Effect, Layer, Match } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
|
|
@ -461,16 +461,15 @@ function parseScoredEdges(responseText: string): Array<typeof ScoredEdge.Type> {
|
|||
}
|
||||
|
||||
export function termToString(term: Term): string {
|
||||
switch (term.type) {
|
||||
case "IRI":
|
||||
return term.iri;
|
||||
case "LITERAL":
|
||||
return term.value;
|
||||
case "BLANK":
|
||||
return `_:${term.id}`;
|
||||
case "TRIPLE":
|
||||
return `(${termToString(term.triple.s)} ${termToString(term.triple.p)} ${termToString(term.triple.o)})`;
|
||||
}
|
||||
return Match.type<Term>().pipe(
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
IRI: (iri) => iri.iri,
|
||||
LITERAL: (literal) => literal.value,
|
||||
BLANK: (blank) => `_:${blank.id}`,
|
||||
TRIPLE: (triple) =>
|
||||
`(${termToString(triple.triple.s)} ${termToString(triple.triple.p)} ${termToString(triple.triple.o)})`,
|
||||
}),
|
||||
)(term);
|
||||
}
|
||||
|
||||
export function stringToTerm(value: string): Term {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import { errorMessage, type Term } from "@trustgraph/base";
|
||||
import { Config, Context, Effect, Layer, Random } from "effect";
|
||||
import { Config, Context, Effect, Layer, Match, Random } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
import { makeQdrantClient, type QdrantClientFactory, type QdrantClientLike } from "../../qdrant/client.js";
|
||||
|
|
@ -84,16 +84,14 @@ const randomPointId = Effect.fn("QdrantGraphEmbeddings.randomPointId")(function*
|
|||
});
|
||||
|
||||
function getTermValue(term: Term): string | null {
|
||||
switch (term.type) {
|
||||
case "IRI":
|
||||
return term.iri;
|
||||
case "LITERAL":
|
||||
return term.value;
|
||||
case "BLANK":
|
||||
return term.id;
|
||||
case "TRIPLE":
|
||||
return null;
|
||||
}
|
||||
return Match.type<Term>().pipe(
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
IRI: (iri) => iri.iri,
|
||||
LITERAL: (literal) => literal.value,
|
||||
BLANK: (blank) => blank.id,
|
||||
TRIPLE: () => null,
|
||||
}),
|
||||
)(term);
|
||||
}
|
||||
|
||||
export interface QdrantGraphEmbeddingsStore {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import { createClient, Graph } from "falkordb";
|
||||
import { errorMessage, type Term, type Triple } from "@trustgraph/base";
|
||||
import { Config, Context, Effect, Layer } from "effect";
|
||||
import { Config, Context, Effect, Layer, Match } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export interface FalkorDBClosableClient {
|
||||
|
|
@ -40,16 +40,14 @@ export interface FalkorDBConfig {
|
|||
}
|
||||
|
||||
function getTermValue(term: Term): string {
|
||||
switch (term.type) {
|
||||
case "IRI":
|
||||
return term.iri;
|
||||
case "LITERAL":
|
||||
return term.value;
|
||||
case "BLANK":
|
||||
return term.id;
|
||||
case "TRIPLE":
|
||||
return getTermValue(term.triple.s);
|
||||
}
|
||||
return Match.type<Term>().pipe(
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
IRI: (iri) => iri.iri,
|
||||
LITERAL: (literal) => literal.value,
|
||||
BLANK: (blank) => blank.id,
|
||||
TRIPLE: (triple) => getTermValue(triple.triple.s),
|
||||
}),
|
||||
)(term);
|
||||
}
|
||||
|
||||
export interface FalkorDBTriplesStore {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue