feat(palace): knowledge graph triple CRUD with temporal queries, contradiction detection (N3)
This commit is contained in:
parent
aa675e855b
commit
31bdb0ada9
144
backend/src/modules/palace/kg.test.ts
Normal file
144
backend/src/modules/palace/kg.test.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { resetMemoryDatastore, TEST_USER_ID, TEST_PRODUCT_ID } from '../../test-helpers.js';
|
||||||
|
import {
|
||||||
|
ensureWing,
|
||||||
|
addTriple,
|
||||||
|
invalidateTriple,
|
||||||
|
queryEntity,
|
||||||
|
queryRelation,
|
||||||
|
entityTimeline,
|
||||||
|
findKGContradictions,
|
||||||
|
} from './repository.js';
|
||||||
|
|
||||||
|
const USER_A = TEST_USER_ID;
|
||||||
|
const USER_B = 'test-user-2';
|
||||||
|
const PRODUCT = TEST_PRODUCT_ID;
|
||||||
|
|
||||||
|
describe('Palace Knowledge Graph (N3)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryDatastore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds and queries a triple', async () => {
|
||||||
|
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
|
||||||
|
const triple = await addTriple(
|
||||||
|
USER_A, PRODUCT, wing.id,
|
||||||
|
'React Router', 'replaced_by', 'TanStack Router', 0.9,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(triple.subject).toBe('React Router');
|
||||||
|
expect(triple.predicate).toBe('replaced_by');
|
||||||
|
expect(triple.object).toBe('TanStack Router');
|
||||||
|
expect(triple.confidence).toBe(0.9);
|
||||||
|
expect(triple.validTo).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queryEntity returns triples about an entity', async () => {
|
||||||
|
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'React Router', 'replaced_by', 'TanStack Router', 0.9);
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'React Router', 'used_in', 'Project Alpha', 0.8);
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'PostgreSQL', 'used_for', 'user data', 0.95);
|
||||||
|
|
||||||
|
const results = await queryEntity(USER_A, PRODUCT, 'react router');
|
||||||
|
expect(results.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidateTriple marks a triple as ended', async () => {
|
||||||
|
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Team', 'uses', 'Express', 0.9);
|
||||||
|
|
||||||
|
const invalidated = await invalidateTriple(USER_A, PRODUCT, 'Team', 'uses', 'Express');
|
||||||
|
expect(invalidated).toBe(true);
|
||||||
|
|
||||||
|
// Query should not return invalidated triple
|
||||||
|
const results = await queryEntity(USER_A, PRODUCT, 'Team');
|
||||||
|
expect(results.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidated triples excluded from current queries', async () => {
|
||||||
|
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'API', 'runs_on', 'Express', 0.9);
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'API', 'uses', 'REST', 0.8);
|
||||||
|
|
||||||
|
await invalidateTriple(USER_A, PRODUCT, 'API', 'runs_on', 'Express');
|
||||||
|
|
||||||
|
const results = await queryEntity(USER_A, PRODUCT, 'API');
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
expect(results[0].predicate).toBe('uses');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queryRelation returns matching triples', async () => {
|
||||||
|
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Service A', 'depends_on', 'Service B', 0.9);
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Service A', 'depends_on', 'Service C', 0.8);
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Service B', 'depends_on', 'Database', 0.95);
|
||||||
|
|
||||||
|
const results = await queryRelation(USER_A, PRODUCT, 'Service A', 'depends_on');
|
||||||
|
expect(results.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entity timeline returns chronological order', async () => {
|
||||||
|
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
|
||||||
|
|
||||||
|
// Add triples with slight delay so validFrom differs
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Framework', 'was', 'Express', 0.9);
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Framework', 'migrated_to', 'Fastify', 0.95);
|
||||||
|
|
||||||
|
const tl = await entityTimeline(USER_A, PRODUCT, 'Framework');
|
||||||
|
expect(tl.length).toBe(2);
|
||||||
|
// First entry should be chronologically earlier or equal
|
||||||
|
expect(tl[0].validFrom <= tl[1].validFrom).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contradiction detection finds conflicting current facts', async () => {
|
||||||
|
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Backend', 'language', 'JavaScript', 0.8);
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Backend', 'language', 'TypeScript', 0.95);
|
||||||
|
|
||||||
|
const contradictions = await findKGContradictions(USER_A, PRODUCT, wing.id);
|
||||||
|
expect(contradictions.length).toBe(1);
|
||||||
|
expect(contradictions[0].a.object).not.toBe(contradictions[0].b.object);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no contradictions when facts are different subjects', async () => {
|
||||||
|
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Frontend', 'language', 'TypeScript', 0.9);
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'Backend', 'language', 'TypeScript', 0.9);
|
||||||
|
|
||||||
|
const contradictions = await findKGContradictions(USER_A, PRODUCT, wing.id);
|
||||||
|
expect(contradictions.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cross-user: user A KG never leaks to user B', async () => {
|
||||||
|
const wingA = await ensureWing(USER_A, PRODUCT, 'ws-a', 'A');
|
||||||
|
const wingB = await ensureWing(USER_B, PRODUCT, 'ws-b', 'B');
|
||||||
|
|
||||||
|
await addTriple(USER_A, PRODUCT, wingA.id, 'SecretProject', 'status', 'active', 0.9);
|
||||||
|
await addTriple(USER_B, PRODUCT, wingB.id, 'PublicProject', 'status', 'completed', 0.9);
|
||||||
|
|
||||||
|
const resultsA = await queryEntity(USER_A, PRODUCT, 'SecretProject');
|
||||||
|
const resultsB = await queryEntity(USER_B, PRODUCT, 'SecretProject');
|
||||||
|
const resultsBOwn = await queryEntity(USER_B, PRODUCT, 'PublicProject');
|
||||||
|
|
||||||
|
expect(resultsA.length).toBe(1);
|
||||||
|
expect(resultsB.length).toBe(0);
|
||||||
|
expect(resultsBOwn.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('temporal query with asOf shows triples valid at that point in time', async () => {
|
||||||
|
const wing = await ensureWing(USER_A, PRODUCT, 'ws-1', 'Work');
|
||||||
|
await addTriple(USER_A, PRODUCT, wing.id, 'DB', 'type', 'MongoDB', 0.9);
|
||||||
|
await invalidateTriple(USER_A, PRODUCT, 'DB', 'type', 'MongoDB');
|
||||||
|
|
||||||
|
// Query as of now — invalidated triple should be excluded
|
||||||
|
const current = await queryEntity(USER_A, PRODUCT, 'DB');
|
||||||
|
expect(current.length).toBe(0);
|
||||||
|
|
||||||
|
// Query as of yesterday — the invalidated triple's validTo is ~now,
|
||||||
|
// which is after yesterday, so it was still valid at that time
|
||||||
|
const pastDate = new Date(Date.now() - 86400000).toISOString();
|
||||||
|
const atPast = await queryEntity(USER_A, PRODUCT, 'DB', pastDate);
|
||||||
|
expect(atPast.length).toBe(1);
|
||||||
|
expect(atPast[0].object).toBe('MongoDB');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -522,6 +522,163 @@ export async function getPalaceStats(
|
|||||||
return { wings, rooms, memories, kgTriples, tunnels, diaries };
|
return { wings, rooms, memories, kgTriples, tunnels, diaries };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Knowledge Graph (KG) ────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function addTriple(
|
||||||
|
userId: string,
|
||||||
|
productId: string,
|
||||||
|
wingId: string,
|
||||||
|
subject: string,
|
||||||
|
predicate: string,
|
||||||
|
object: string,
|
||||||
|
confidence: number,
|
||||||
|
sourceMemoryId?: string,
|
||||||
|
): Promise<PalaceKGTripleDoc> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const doc: PalaceKGTripleDoc = {
|
||||||
|
id: `kg-${crypto.randomUUID()}`,
|
||||||
|
productId,
|
||||||
|
userId,
|
||||||
|
wingId,
|
||||||
|
subject,
|
||||||
|
predicate,
|
||||||
|
object,
|
||||||
|
confidence,
|
||||||
|
validFrom: now,
|
||||||
|
sourceMemoryId,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
return kgCollection().create(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invalidateTriple(
|
||||||
|
userId: string,
|
||||||
|
productId: string,
|
||||||
|
subject: string,
|
||||||
|
predicate: string,
|
||||||
|
object: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const all = await kgCollection().findMany({
|
||||||
|
filter: { userId, productId },
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sNorm = subject.toLowerCase().trim();
|
||||||
|
const pNorm = predicate.toLowerCase().trim();
|
||||||
|
const oNorm = object.toLowerCase().trim();
|
||||||
|
|
||||||
|
for (const t of all) {
|
||||||
|
if (
|
||||||
|
t.subject.toLowerCase().trim() === sNorm &&
|
||||||
|
t.predicate.toLowerCase().trim() === pNorm &&
|
||||||
|
t.object.toLowerCase().trim() === oNorm &&
|
||||||
|
!t.validTo
|
||||||
|
) {
|
||||||
|
await kgCollection().update(t.id, userId, {
|
||||||
|
validTo: new Date().toISOString(),
|
||||||
|
} as Partial<PalaceKGTripleDoc>);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryEntity(
|
||||||
|
userId: string,
|
||||||
|
productId: string,
|
||||||
|
entity: string,
|
||||||
|
asOf?: string,
|
||||||
|
): Promise<PalaceKGTripleDoc[]> {
|
||||||
|
const all = await kgCollection().findMany({
|
||||||
|
filter: { userId, productId },
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const norm = entity.toLowerCase().trim();
|
||||||
|
const ref = asOf ? new Date(asOf) : new Date();
|
||||||
|
|
||||||
|
return all.filter(t => {
|
||||||
|
const matchesEntity =
|
||||||
|
t.subject.toLowerCase().trim() === norm ||
|
||||||
|
t.object.toLowerCase().trim() === norm;
|
||||||
|
if (!matchesEntity) return false;
|
||||||
|
|
||||||
|
// Current at asOf
|
||||||
|
if (t.validTo && new Date(t.validTo).getTime() <= ref.getTime()) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryRelation(
|
||||||
|
userId: string,
|
||||||
|
productId: string,
|
||||||
|
subject: string,
|
||||||
|
predicate: string,
|
||||||
|
): Promise<PalaceKGTripleDoc[]> {
|
||||||
|
const all = await kgCollection().findMany({
|
||||||
|
filter: { userId, productId },
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sNorm = subject.toLowerCase().trim();
|
||||||
|
const pNorm = predicate.toLowerCase().trim();
|
||||||
|
|
||||||
|
return all.filter(t =>
|
||||||
|
t.subject.toLowerCase().trim() === sNorm &&
|
||||||
|
t.predicate.toLowerCase().trim() === pNorm &&
|
||||||
|
!t.validTo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function entityTimeline(
|
||||||
|
userId: string,
|
||||||
|
productId: string,
|
||||||
|
entity: string,
|
||||||
|
): Promise<PalaceKGTripleDoc[]> {
|
||||||
|
const all = await kgCollection().findMany({
|
||||||
|
filter: { userId, productId },
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const norm = entity.toLowerCase().trim();
|
||||||
|
|
||||||
|
return all
|
||||||
|
.filter(t =>
|
||||||
|
t.subject.toLowerCase().trim() === norm ||
|
||||||
|
t.object.toLowerCase().trim() === norm,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.validFrom.localeCompare(b.validFrom));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findKGContradictions(
|
||||||
|
userId: string,
|
||||||
|
productId: string,
|
||||||
|
wingId?: string,
|
||||||
|
): Promise<Array<{ a: PalaceKGTripleDoc; b: PalaceKGTripleDoc }>> {
|
||||||
|
const filter: FilterMap = { userId, productId };
|
||||||
|
if (wingId) filter.wingId = wingId;
|
||||||
|
|
||||||
|
const all = await kgCollection().findMany({ filter, limit: 500 });
|
||||||
|
const current = all.filter(t => !t.validTo);
|
||||||
|
const contradictions: Array<{ a: PalaceKGTripleDoc; b: PalaceKGTripleDoc }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < current.length; i++) {
|
||||||
|
for (let j = i + 1; j < current.length; j++) {
|
||||||
|
const a = current[i];
|
||||||
|
const b = current[j];
|
||||||
|
if (
|
||||||
|
a.subject.toLowerCase().trim() === b.subject.toLowerCase().trim() &&
|
||||||
|
a.predicate.toLowerCase().trim() === b.predicate.toLowerCase().trim() &&
|
||||||
|
a.object.toLowerCase().trim() !== b.object.toLowerCase().trim()
|
||||||
|
) {
|
||||||
|
contradictions.push({ a, b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contradictions;
|
||||||
|
}
|
||||||
|
|
||||||
export async function healthCheck(): Promise<{ cosmos: boolean; llm: boolean }> {
|
export async function healthCheck(): Promise<{ cosmos: boolean; llm: boolean }> {
|
||||||
let cosmos = false;
|
let cosmos = false;
|
||||||
let llm = false;
|
let llm = false;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user