feat: add topic hierarchy APIs

This commit is contained in:
2026-05-11 11:46:10 +09:00
parent d95ac8c1a0
commit ba03ecb85b
9 changed files with 223 additions and 2 deletions

View File

@@ -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);

View File

@@ -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();
} }

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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;
}

View File

@@ -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 () => {

View File

@@ -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',
]);
});
}); });