feat: add topic hierarchy APIs
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
type TopicWithFacts,
|
type TopicWithFacts,
|
||||||
type UpsertTopicInput,
|
type UpsertTopicInput,
|
||||||
type AddFactInput,
|
type AddFactInput,
|
||||||
|
type LinkTopicsInput,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
import type { IngestStatementOptions } from '../ingestion/types';
|
import type { IngestStatementOptions } from '../ingestion/types';
|
||||||
import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect';
|
import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect';
|
||||||
@@ -35,6 +36,8 @@ import {
|
|||||||
findConnectedTopicRows,
|
findConnectedTopicRows,
|
||||||
findTopicRowByNormalizedName,
|
findTopicRowByNormalizedName,
|
||||||
listTopicRows,
|
listTopicRows,
|
||||||
|
findChildTopicRows,
|
||||||
|
findParentTopicRows,
|
||||||
type DatabaseExecutor,
|
type DatabaseExecutor,
|
||||||
} from '../queries/topics';
|
} from '../queries/topics';
|
||||||
|
|
||||||
@@ -152,6 +155,105 @@ export class IdentityDB {
|
|||||||
return this.addFact(factInput);
|
return this.addFact(factInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async linkTopics(input: LinkTopicsInput): Promise<void> {
|
||||||
|
const parentNormalizedName = normalizeTopicName(input.parentName);
|
||||||
|
const childNormalizedName = normalizeTopicName(input.childName);
|
||||||
|
|
||||||
|
if (parentNormalizedName.length === 0 || childNormalizedName.length === 0) {
|
||||||
|
throw new IdentityDBError('Topic hierarchy links require both a parent and child topic name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentNormalizedName === childNormalizedName) {
|
||||||
|
throw new IdentityDBError('A topic cannot be linked as its own parent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.connection.db.transaction().execute(async (trx) => {
|
||||||
|
const parentTopic = await this.upsertTopicInExecutor(trx, {
|
||||||
|
name: input.parentName,
|
||||||
|
granularity: 'abstract',
|
||||||
|
});
|
||||||
|
const childTopic = await this.upsertTopicInExecutor(trx, {
|
||||||
|
name: input.childName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = await trx
|
||||||
|
.selectFrom('topic_relations')
|
||||||
|
.select(['parent_topic_id'])
|
||||||
|
.where('parent_topic_id', '=', parentTopic.id)
|
||||||
|
.where('child_topic_id', '=', childTopic.id)
|
||||||
|
.where('relation', '=', 'parent_of')
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await trx
|
||||||
|
.insertInto('topic_relations')
|
||||||
|
.values({
|
||||||
|
parent_topic_id: parentTopic.id,
|
||||||
|
child_topic_id: childTopic.id,
|
||||||
|
relation: 'parent_of',
|
||||||
|
created_at: nowIsoString(),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopicChildren(name: string): Promise<Topic[]> {
|
||||||
|
const topicRow = await this.getRequiredTopicRow(name);
|
||||||
|
|
||||||
|
if (!topicRow) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const childRows = await findChildTopicRows(this.connection.db, topicRow.id);
|
||||||
|
return childRows.map(mapTopicRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopicParents(name: string): Promise<Topic[]> {
|
||||||
|
const topicRow = await this.getRequiredTopicRow(name);
|
||||||
|
|
||||||
|
if (!topicRow) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentRows = await findParentTopicRows(this.connection.db, topicRow.id);
|
||||||
|
return parentRows.map(mapTopicRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopicLineage(name: string): Promise<Topic[]> {
|
||||||
|
const topicRow = await this.getRequiredTopicRow(name);
|
||||||
|
|
||||||
|
if (!topicRow) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineage: Topic[] = [];
|
||||||
|
const visitedTopicIds = new Set<string>([topicRow.id]);
|
||||||
|
let currentLevelIds = [topicRow.id];
|
||||||
|
|
||||||
|
while (currentLevelIds.length > 0) {
|
||||||
|
const nextLevelIds: string[] = [];
|
||||||
|
|
||||||
|
for (const currentId of currentLevelIds) {
|
||||||
|
const parentRows = await findParentTopicRows(this.connection.db, currentId);
|
||||||
|
|
||||||
|
for (const parentRow of parentRows) {
|
||||||
|
if (visitedTopicIds.has(parentRow.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visitedTopicIds.add(parentRow.id);
|
||||||
|
nextLevelIds.push(parentRow.id);
|
||||||
|
lineage.push(mapTopicRow(parentRow));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevelIds = nextLevelIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lineage;
|
||||||
|
}
|
||||||
|
|
||||||
async getTopicFacts(name: string): Promise<Fact[]> {
|
async getTopicFacts(name: string): Promise<Fact[]> {
|
||||||
const topicRow = await this.getRequiredTopicRow(name);
|
const topicRow = await this.getRequiredTopicRow(name);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Kysely } from 'kysely';
|
|||||||
import {
|
import {
|
||||||
FACTS_TABLE,
|
FACTS_TABLE,
|
||||||
FACT_TOPICS_TABLE,
|
FACT_TOPICS_TABLE,
|
||||||
|
TOPIC_RELATIONS_TABLE,
|
||||||
TOPICS_TABLE,
|
TOPICS_TABLE,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
import type { IdentityDatabaseSchema } from '../types/database';
|
import type { IdentityDatabaseSchema } from '../types/database';
|
||||||
@@ -52,6 +53,20 @@ export async function initializeSchema(
|
|||||||
.addPrimaryKeyConstraint('fact_topics_pk', ['fact_id', 'topic_id', 'position'])
|
.addPrimaryKeyConstraint('fact_topics_pk', ['fact_id', 'topic_id', 'position'])
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable(TOPIC_RELATIONS_TABLE)
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('parent_topic_id', 'text', (column) =>
|
||||||
|
column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('child_topic_id', 'text', (column) =>
|
||||||
|
column.notNull().references(`${TOPICS_TABLE}.id`).onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('relation', 'text', (column) => column.notNull())
|
||||||
|
.addColumn('created_at', 'text', (column) => column.notNull())
|
||||||
|
.addPrimaryKeyConstraint('topic_relations_pk', ['parent_topic_id', 'child_topic_id', 'relation'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
await db.schema
|
await db.schema
|
||||||
.createIndex('fact_topics_topic_id_idx')
|
.createIndex('fact_topics_topic_id_idx')
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
@@ -65,4 +80,18 @@ export async function initializeSchema(
|
|||||||
.on(FACT_TOPICS_TABLE)
|
.on(FACT_TOPICS_TABLE)
|
||||||
.column('fact_id')
|
.column('fact_id')
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('topic_relations_parent_topic_id_idx')
|
||||||
|
.ifNotExists()
|
||||||
|
.on(TOPIC_RELATIONS_TABLE)
|
||||||
|
.column('parent_topic_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('topic_relations_child_topic_id_idx')
|
||||||
|
.ifNotExists()
|
||||||
|
.on(TOPIC_RELATIONS_TABLE)
|
||||||
|
.column('child_topic_id')
|
||||||
|
.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const TOPICS_TABLE = 'topics';
|
export const TOPICS_TABLE = 'topics';
|
||||||
export const FACTS_TABLE = 'facts';
|
export const FACTS_TABLE = 'facts';
|
||||||
export const FACT_TOPICS_TABLE = 'fact_topics';
|
export const FACT_TOPICS_TABLE = 'fact_topics';
|
||||||
|
export const TOPIC_RELATIONS_TABLE = 'topic_relations';
|
||||||
|
|
||||||
export const TOPIC_COLUMNS = [
|
export const TOPIC_COLUMNS = [
|
||||||
'id',
|
'id',
|
||||||
@@ -32,3 +33,10 @@ export const FACT_TOPIC_COLUMNS = [
|
|||||||
'position',
|
'position',
|
||||||
'created_at',
|
'created_at',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const TOPIC_RELATION_COLUMNS = [
|
||||||
|
'parent_topic_id',
|
||||||
|
'child_topic_id',
|
||||||
|
'relation',
|
||||||
|
'created_at',
|
||||||
|
] as const;
|
||||||
|
|||||||
@@ -53,3 +53,31 @@ export async function findConnectedTopicRows(
|
|||||||
.orderBy('topics.normalized_name', 'asc')
|
.orderBy('topics.normalized_name', 'asc')
|
||||||
.execute() as Promise<ConnectedTopicRow[]>;
|
.execute() as Promise<ConnectedTopicRow[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findChildTopicRows(
|
||||||
|
executor: DatabaseExecutor,
|
||||||
|
parentTopicId: string,
|
||||||
|
): Promise<TopicRecord[]> {
|
||||||
|
return executor
|
||||||
|
.selectFrom('topic_relations')
|
||||||
|
.innerJoin('topics', 'topics.id', 'topic_relations.child_topic_id')
|
||||||
|
.selectAll('topics')
|
||||||
|
.where('topic_relations.parent_topic_id', '=', parentTopicId)
|
||||||
|
.where('topic_relations.relation', '=', 'parent_of')
|
||||||
|
.orderBy('topics.normalized_name', 'asc')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findParentTopicRows(
|
||||||
|
executor: DatabaseExecutor,
|
||||||
|
childTopicId: string,
|
||||||
|
): Promise<TopicRecord[]> {
|
||||||
|
return executor
|
||||||
|
.selectFrom('topic_relations')
|
||||||
|
.innerJoin('topics', 'topics.id', 'topic_relations.parent_topic_id')
|
||||||
|
.selectAll('topics')
|
||||||
|
.where('topic_relations.child_topic_id', '=', childTopicId)
|
||||||
|
.where('topic_relations.relation', '=', 'parent_of')
|
||||||
|
.orderBy('topics.normalized_name', 'asc')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface AddFactInput {
|
|||||||
topics: TopicLinkInput[];
|
topics: TopicLinkInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkTopicsInput {
|
||||||
|
parentName: string;
|
||||||
|
childName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Topic {
|
export interface Topic {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { FactRecord, FactTopicRecord, TopicRecord } from './domain';
|
import type { FactRecord, FactTopicRecord, TopicRecord, TopicRelationRecord } from './domain';
|
||||||
|
|
||||||
export interface IdentityDatabaseSchema {
|
export interface IdentityDatabaseSchema {
|
||||||
topics: TopicRecord;
|
topics: TopicRecord;
|
||||||
facts: FactRecord;
|
facts: FactRecord;
|
||||||
fact_topics: FactTopicRecord;
|
fact_topics: FactTopicRecord;
|
||||||
|
topic_relations: TopicRelationRecord;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,10 @@ export interface FactTopicRecord {
|
|||||||
position: number;
|
position: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopicRelationRecord {
|
||||||
|
parent_topic_id: string;
|
||||||
|
child_topic_id: string;
|
||||||
|
relation: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('initializeSchema', () => {
|
describe('initializeSchema', () => {
|
||||||
it('creates the topics, facts, and fact_topics tables', async () => {
|
it('creates the topics, facts, fact_topics, and topic_relations tables', async () => {
|
||||||
const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' });
|
const connection = await createDatabase({ client: 'sqlite', filename: ':memory:' });
|
||||||
openConnections.push(connection.destroy);
|
openConnections.push(connection.destroy);
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ describe('initializeSchema', () => {
|
|||||||
expect(tableNames).toContain('topics');
|
expect(tableNames).toContain('topics');
|
||||||
expect(tableNames).toContain('facts');
|
expect(tableNames).toContain('facts');
|
||||||
expect(tableNames).toContain('fact_topics');
|
expect(tableNames).toContain('fact_topics');
|
||||||
|
expect(tableNames).toContain('topic_relations');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates the expected columns for each table', async () => {
|
it('creates the expected columns for each table', async () => {
|
||||||
@@ -45,6 +46,7 @@ describe('initializeSchema', () => {
|
|||||||
const topicsColumns = await sql<{ name: string }>`PRAGMA table_info(topics)`.execute(connection.db);
|
const topicsColumns = await sql<{ name: string }>`PRAGMA table_info(topics)`.execute(connection.db);
|
||||||
const factsColumns = await sql<{ name: string }>`PRAGMA table_info(facts)`.execute(connection.db);
|
const factsColumns = await sql<{ name: string }>`PRAGMA table_info(facts)`.execute(connection.db);
|
||||||
const factTopicsColumns = await sql<{ name: string }>`PRAGMA table_info(fact_topics)`.execute(connection.db);
|
const factTopicsColumns = await sql<{ name: string }>`PRAGMA table_info(fact_topics)`.execute(connection.db);
|
||||||
|
const topicRelationsColumns = await sql<{ name: string }>`PRAGMA table_info(topic_relations)`.execute(connection.db);
|
||||||
|
|
||||||
expect(topicsColumns.rows.map((row) => row.name)).toEqual([
|
expect(topicsColumns.rows.map((row) => row.name)).toEqual([
|
||||||
'id',
|
'id',
|
||||||
@@ -76,6 +78,13 @@ describe('initializeSchema', () => {
|
|||||||
'position',
|
'position',
|
||||||
'created_at',
|
'created_at',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
expect(topicRelationsColumns.rows.map((row) => row.name)).toEqual([
|
||||||
|
'parent_topic_id',
|
||||||
|
'child_topic_id',
|
||||||
|
'relation',
|
||||||
|
'created_at',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is idempotent when called more than once', async () => {
|
it('is idempotent when called more than once', async () => {
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ async function seedMemoryGraph(db: IdentityDB): Promise<void> {
|
|||||||
{ name: 'programming language', category: 'concept', granularity: 'abstract', role: 'classification' },
|
{ name: 'programming language', category: 'concept', granularity: 'abstract', role: 'classification' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await db.linkTopics({
|
||||||
|
parentName: 'software technology',
|
||||||
|
childName: 'programming language',
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.linkTopics({
|
||||||
|
parentName: 'programming language',
|
||||||
|
childName: 'TypeScript',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('IdentityDB queries', () => {
|
describe('IdentityDB queries', () => {
|
||||||
@@ -60,6 +70,7 @@ describe('IdentityDB queries', () => {
|
|||||||
'2025',
|
'2025',
|
||||||
'I',
|
'I',
|
||||||
'programming language',
|
'programming language',
|
||||||
|
'software technology',
|
||||||
'TypeScript',
|
'TypeScript',
|
||||||
]);
|
]);
|
||||||
expect('facts' in topics[0]!).toBe(false);
|
expect('facts' in topics[0]!).toBe(false);
|
||||||
@@ -81,4 +92,25 @@ describe('IdentityDB queries', () => {
|
|||||||
expect(facts).toHaveLength(1);
|
expect(facts).toHaveLength(1);
|
||||||
expect(facts[0]?.statement).toBe('I have worked with TypeScript since 2025.');
|
expect(facts[0]?.statement).toBe('I have worked with TypeScript since 2025.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists direct child topics for a parent topic', async () => {
|
||||||
|
const children = await db.getTopicChildren('programming language');
|
||||||
|
|
||||||
|
expect(children.map((topic) => topic.name)).toEqual(['TypeScript']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists direct parent topics for a child topic', async () => {
|
||||||
|
const parents = await db.getTopicParents('TypeScript');
|
||||||
|
|
||||||
|
expect(parents.map((topic) => topic.name)).toEqual(['programming language']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns lineage from nearest parent outward', async () => {
|
||||||
|
const lineage = await db.getTopicLineage('TypeScript');
|
||||||
|
|
||||||
|
expect(lineage.map((topic) => topic.name)).toEqual([
|
||||||
|
'programming language',
|
||||||
|
'software technology',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user