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 UpsertTopicInput,
type AddFactInput,
type LinkTopicsInput,
} from '../types/api';
import type { IngestStatementOptions } from '../ingestion/types';
import type { DatabaseConnection, IdentityDBConnectionConfig } from '../adapters/dialect';
@@ -35,6 +36,8 @@ import {
findConnectedTopicRows,
findTopicRowByNormalizedName,
listTopicRows,
findChildTopicRows,
findParentTopicRows,
type DatabaseExecutor,
} from '../queries/topics';
@@ -152,6 +155,105 @@ export class IdentityDB {
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[]> {
const topicRow = await this.getRequiredTopicRow(name);