feat: port holistic node refinement contract
This commit is contained in:
@@ -1,204 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { query, transaction, getDb } = require('./sqlite-client');
|
||||
|
||||
/**
|
||||
* Get all dimensions with counts.
|
||||
*/
|
||||
function getDimensions() {
|
||||
const sql = `
|
||||
WITH dimension_counts AS (
|
||||
SELECT nd.dimension, COUNT(*) AS count
|
||||
FROM node_dimensions nd
|
||||
GROUP BY nd.dimension
|
||||
)
|
||||
SELECT
|
||||
d.name AS dimension,
|
||||
d.description,
|
||||
d.icon,
|
||||
d.is_priority AS isPriority,
|
||||
COALESCE(dc.count, 0) AS count
|
||||
FROM dimensions d
|
||||
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
|
||||
ORDER BY d.is_priority DESC, d.name ASC
|
||||
`;
|
||||
|
||||
const rows = query(sql);
|
||||
|
||||
return rows.map(row => ({
|
||||
dimension: row.dimension,
|
||||
description: row.description,
|
||||
icon: row.icon || null,
|
||||
isPriority: Boolean(row.isPriority),
|
||||
count: Number(row.count)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a dimension.
|
||||
*/
|
||||
function createDimension(data) {
|
||||
const { name, description, isPriority = false } = data;
|
||||
const db = getDb();
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error('Dimension name is required');
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO dimensions(name, description, is_priority, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
description = COALESCE(?, description),
|
||||
is_priority = COALESCE(?, is_priority),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING name, description, is_priority
|
||||
`);
|
||||
|
||||
const rows = stmt.all(
|
||||
trimmedName,
|
||||
description ?? null,
|
||||
isPriority ? 1 : 0,
|
||||
description ?? null,
|
||||
isPriority ? 1 : 0
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error('Failed to create dimension');
|
||||
}
|
||||
|
||||
return {
|
||||
dimension: rows[0].name,
|
||||
description: rows[0].description,
|
||||
isPriority: Boolean(rows[0].is_priority)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a dimension.
|
||||
*/
|
||||
function updateDimension(data) {
|
||||
const { name, currentName, newName, description, isPriority } = data;
|
||||
const db = getDb();
|
||||
|
||||
// Handle rename
|
||||
if (currentName && newName && currentName !== newName) {
|
||||
// Check if new name already exists
|
||||
const existing = query('SELECT name FROM dimensions WHERE name = ?', [newName]);
|
||||
if (existing.length > 0) {
|
||||
throw new Error('A dimension with this name already exists');
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
// Update dimensions table
|
||||
const dimStmt = db.prepare(`
|
||||
UPDATE dimensions
|
||||
SET name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE name = ?
|
||||
`);
|
||||
const dimResult = dimStmt.run(newName, currentName);
|
||||
|
||||
if (dimResult.changes === 0) {
|
||||
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
|
||||
}
|
||||
|
||||
// Update node_dimensions
|
||||
const nodeDimStmt = db.prepare(`
|
||||
UPDATE node_dimensions
|
||||
SET dimension = ?
|
||||
WHERE dimension = ?
|
||||
`);
|
||||
nodeDimStmt.run(newName, currentName);
|
||||
});
|
||||
|
||||
return {
|
||||
dimension: newName,
|
||||
previousName: currentName,
|
||||
renamed: true
|
||||
};
|
||||
}
|
||||
|
||||
// Handle update (description/isPriority)
|
||||
const targetName = name || currentName;
|
||||
if (!targetName) {
|
||||
throw new Error('Dimension name is required');
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (description !== undefined) {
|
||||
updates.push('description = ?');
|
||||
params.push(description);
|
||||
}
|
||||
|
||||
if (isPriority !== undefined) {
|
||||
updates.push('is_priority = ?');
|
||||
params.push(isPriority ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('At least one update field must be provided (description, isPriority, or newName).');
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
params.push(targetName);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE dimensions
|
||||
SET ${updates.join(', ')}
|
||||
WHERE name = ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(...params);
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
|
||||
}
|
||||
|
||||
return {
|
||||
dimension: targetName,
|
||||
description,
|
||||
isPriority
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a dimension.
|
||||
*/
|
||||
function deleteDimension(name) {
|
||||
const db = getDb();
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error('Dimension name is required');
|
||||
}
|
||||
|
||||
const removal = transaction(() => {
|
||||
const nodeDimStmt = db.prepare('DELETE FROM node_dimensions WHERE dimension = ?');
|
||||
const dimStmt = db.prepare('DELETE FROM dimensions WHERE name = ?');
|
||||
|
||||
const removedLinks = nodeDimStmt.run(name).changes ?? 0;
|
||||
const removedRow = dimStmt.run(name).changes ?? 0;
|
||||
|
||||
return { removedLinks, removedRow };
|
||||
});
|
||||
|
||||
if (!removal.removedLinks && !removal.removedRow) {
|
||||
throw new Error('Dimension not found. Use rah_list_dimensions to see all dimensions.');
|
||||
}
|
||||
|
||||
return {
|
||||
dimension: name,
|
||||
deleted: true,
|
||||
removedLinks: removal.removedLinks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDimensions,
|
||||
createDimension,
|
||||
updateDimension,
|
||||
deleteDimension
|
||||
};
|
||||
@@ -53,10 +53,8 @@ function buildCanonicalMetadata({ existing, metadata }) {
|
||||
function mapNodeRow(row) {
|
||||
return {
|
||||
...row,
|
||||
dimensions: JSON.parse(row.dimensions_json || '[]'),
|
||||
metadata: row.metadata ? (typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata) : null,
|
||||
context: row.context_json ? JSON.parse(row.context_json) : null,
|
||||
dimensions_json: undefined,
|
||||
context_json: undefined,
|
||||
};
|
||||
}
|
||||
@@ -65,13 +63,11 @@ function mapNodeRow(row) {
|
||||
* Get nodes with optional filtering.
|
||||
*/
|
||||
function getNodes(filters = {}) {
|
||||
const { dimensions, search, limit = 100, offset = 0, contextId } = filters;
|
||||
const { search, limit = 100, offset = 0, contextId } = filters;
|
||||
|
||||
let sql = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
@@ -82,16 +78,6 @@ function getNodes(filters = {}) {
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
// Filter by dimensions
|
||||
if (dimensions && dimensions.length > 0) {
|
||||
sql += ` AND EXISTS (
|
||||
SELECT 1 FROM node_dimensions nd
|
||||
WHERE nd.node_id = n.id
|
||||
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
|
||||
)`;
|
||||
params.push(...dimensions);
|
||||
}
|
||||
|
||||
// Text search
|
||||
if (search) {
|
||||
sql += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.source LIKE ? COLLATE NOCASE)`;
|
||||
@@ -135,8 +121,6 @@ function getNodeById(id) {
|
||||
const sql = `
|
||||
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
|
||||
n.created_at, n.updated_at, n.context_id,
|
||||
COALESCE((SELECT JSON_GROUP_ARRAY(d.dimension)
|
||||
FROM node_dimensions d WHERE d.node_id = n.id), '[]') as dimensions_json,
|
||||
CASE
|
||||
WHEN c.id IS NULL THEN NULL
|
||||
ELSE json_object('id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon)
|
||||
@@ -164,134 +148,6 @@ function sanitizeTitle(title) {
|
||||
return clean.slice(0, 160);
|
||||
}
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'i',
|
||||
'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the', 'their', 'this',
|
||||
'to', 'was', 'with', 'you', 'your'
|
||||
]);
|
||||
|
||||
function normalizeText(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.toLowerCase().replace(/[^a-z0-9\s]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function tokenize(value) {
|
||||
return normalizeText(value)
|
||||
.split(' ')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
|
||||
}
|
||||
|
||||
function uniqueTokens(values) {
|
||||
return [...new Set(values.flatMap((value) => tokenize(value || '')))];
|
||||
}
|
||||
|
||||
function safeStringify(value) {
|
||||
try {
|
||||
return JSON.stringify(value ?? {});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function fetchContextCandidates() {
|
||||
return query(`
|
||||
WITH context_counts AS (
|
||||
SELECT c.id, c.name, c.description, COUNT(n.id) AS count
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
GROUP BY c.id
|
||||
),
|
||||
ranked_anchors AS (
|
||||
SELECT
|
||||
c.id AS context_id,
|
||||
n.title AS anchor_title,
|
||||
n.description AS anchor_description,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.id
|
||||
ORDER BY COUNT(e.id) DESC, n.updated_at DESC, n.id ASC
|
||||
) AS anchor_rank
|
||||
FROM contexts c
|
||||
LEFT JOIN nodes n ON n.context_id = c.id
|
||||
LEFT JOIN edges e ON (e.from_node_id = n.id OR e.to_node_id = n.id)
|
||||
GROUP BY c.id, n.id
|
||||
)
|
||||
SELECT
|
||||
cc.id,
|
||||
cc.name,
|
||||
cc.description,
|
||||
cc.count,
|
||||
ra.anchor_title,
|
||||
ra.anchor_description
|
||||
FROM context_counts cc
|
||||
LEFT JOIN ranked_anchors ra
|
||||
ON ra.context_id = cc.id
|
||||
AND ra.anchor_rank = 1
|
||||
ORDER BY cc.name COLLATE NOCASE ASC
|
||||
`).map((row) => ({
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
description: row.description ?? null,
|
||||
count: Number(row.count ?? 0),
|
||||
anchor_title: row.anchor_title ?? null,
|
||||
anchor_description: row.anchor_description ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function scoreContextCandidate(candidate, input) {
|
||||
const titleText = normalizeText(input.title || '');
|
||||
const descriptionText = normalizeText(input.description || '');
|
||||
const sourceText = normalizeText(String(input.source || '').slice(0, 4000));
|
||||
const metadataText = normalizeText(safeStringify(input.metadata));
|
||||
const dimensionTokens = uniqueTokens(input.dimensions || []);
|
||||
const contextName = normalizeText(candidate.name);
|
||||
const contextNameTokens = tokenize(candidate.name);
|
||||
const contextDescriptorTokens = uniqueTokens([
|
||||
candidate.description,
|
||||
candidate.anchor_title,
|
||||
candidate.anchor_description,
|
||||
]);
|
||||
|
||||
let score = 0;
|
||||
if (contextName && (titleText.includes(contextName) || descriptionText.includes(contextName))) score += 80;
|
||||
if (contextName && sourceText.includes(contextName)) score += 40;
|
||||
|
||||
for (const token of contextNameTokens) {
|
||||
if (dimensionTokens.includes(token)) score += 30;
|
||||
if (titleText.includes(token)) score += 16;
|
||||
if (descriptionText.includes(token)) score += 12;
|
||||
if (sourceText.includes(token)) score += 6;
|
||||
if (metadataText.includes(token)) score += 4;
|
||||
}
|
||||
|
||||
for (const token of contextDescriptorTokens) {
|
||||
if (dimensionTokens.includes(token)) score += 8;
|
||||
if (titleText.includes(token)) score += 4;
|
||||
if (descriptionText.includes(token)) score += 3;
|
||||
if (sourceText.includes(token)) score += 2;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function inferBestContextIdForNode(input) {
|
||||
const contexts = fetchContextCandidates();
|
||||
if (contexts.length === 0) return null;
|
||||
|
||||
const ranked = contexts
|
||||
.map((context) => ({ context, score: scoreContextCandidate(context, input) }))
|
||||
.sort((a, b) => b.score - a.score || (b.context.count - a.context.count) || a.context.id - b.context.id);
|
||||
|
||||
const best = ranked[0];
|
||||
if (!best) return null;
|
||||
if (best.score > 0) return best.context.id;
|
||||
|
||||
const research = contexts.find((context) => context.name.trim().toLowerCase() === 'research');
|
||||
if (research) return research.id;
|
||||
|
||||
return best.context.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node.
|
||||
*/
|
||||
@@ -302,7 +158,6 @@ function createNode(nodeData) {
|
||||
source,
|
||||
link,
|
||||
event_date,
|
||||
dimensions = [],
|
||||
metadata = {},
|
||||
context_id
|
||||
} = nodeData;
|
||||
@@ -314,9 +169,7 @@ function createNode(nodeData) {
|
||||
const db = getDb();
|
||||
|
||||
const sourceToStore = source ?? ([title, description].filter(Boolean).join('\n\n').trim() || null);
|
||||
const effectiveContextId = context_id == null
|
||||
? inferBestContextIdForNode({ title, description, source: sourceToStore, dimensions, metadata: canonicalMetadata })
|
||||
: context_id;
|
||||
const effectiveContextId = context_id ?? null;
|
||||
|
||||
const nodeId = transaction(() => {
|
||||
const stmt = db.prepare(`
|
||||
@@ -338,16 +191,6 @@ function createNode(nodeData) {
|
||||
|
||||
const id = Number(result.lastInsertRowid);
|
||||
|
||||
// Insert dimensions
|
||||
if (dimensions.length > 0) {
|
||||
const dimStmt = db.prepare(
|
||||
'INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)'
|
||||
);
|
||||
for (const dimension of dimensions) {
|
||||
dimStmt.run(id, dimension);
|
||||
}
|
||||
}
|
||||
|
||||
return id;
|
||||
});
|
||||
|
||||
@@ -358,7 +201,7 @@ function createNode(nodeData) {
|
||||
* Update an existing node.
|
||||
*/
|
||||
function updateNode(id, updates, options = {}) {
|
||||
const { title, description, source, link, event_date, dimensions, metadata } = updates;
|
||||
const { title, description, source, link, event_date, metadata } = updates;
|
||||
const now = new Date().toISOString();
|
||||
const db = getDb();
|
||||
|
||||
@@ -415,14 +258,6 @@ function updateNode(id, updates, options = {}) {
|
||||
stmt.run(...params);
|
||||
}
|
||||
|
||||
// Handle dimensions separately
|
||||
if (Array.isArray(dimensions)) {
|
||||
db.prepare('DELETE FROM node_dimensions WHERE node_id = ?').run(id);
|
||||
const dimStmt = db.prepare('INSERT OR IGNORE INTO node_dimensions (node_id, dimension) VALUES (?, ?)');
|
||||
for (const dim of dimensions) {
|
||||
dimStmt.run(id, dim);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return getNodeById(id);
|
||||
@@ -449,21 +284,15 @@ function getNodeCount() {
|
||||
|
||||
/**
|
||||
* Get knowledge graph context overview.
|
||||
* Returns stats, contexts, hub nodes, dimensions, and recent activity.
|
||||
* Returns stats, contexts, hub nodes, and recent activity.
|
||||
*/
|
||||
function getContext() {
|
||||
const nodeCount = query('SELECT COUNT(*) as count FROM nodes')[0].count;
|
||||
const edgeCount = query('SELECT COUNT(*) as count FROM edges')[0].count;
|
||||
|
||||
const dimensionService = require('./dimensionService');
|
||||
const dimensions = dimensionService.getDimensions();
|
||||
|
||||
const recentNodes = query(`
|
||||
SELECT n.id, n.title, n.description,
|
||||
GROUP_CONCAT(nd.dimension) as dimensions
|
||||
SELECT n.id, n.title, n.description
|
||||
FROM nodes n
|
||||
LEFT JOIN node_dimensions nd ON n.id = nd.node_id
|
||||
GROUP BY n.id
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 5
|
||||
`);
|
||||
@@ -478,9 +307,8 @@ function getContext() {
|
||||
`);
|
||||
|
||||
return {
|
||||
stats: { nodeCount, edgeCount, dimensionCount: dimensions.length, contextCount: contextService.listContexts().length },
|
||||
stats: { nodeCount, edgeCount, dimensionCount: 0, contextCount: contextService.listContexts().length },
|
||||
contexts: contextService.listContexts(),
|
||||
dimensions,
|
||||
recentNodes,
|
||||
hubNodes
|
||||
};
|
||||
|
||||
@@ -43,75 +43,6 @@ function initDatabase() {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
db = new Database(dbPath);
|
||||
console.error('[RA-H] Creating new database at:', dbPath);
|
||||
|
||||
// Create core schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
source TEXT,
|
||||
link TEXT,
|
||||
event_date TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
metadata TEXT,
|
||||
embedding BLOB,
|
||||
embedding_updated_at TEXT,
|
||||
embedding_text TEXT,
|
||||
chunk_status TEXT DEFAULT 'not_chunked',
|
||||
context_id INTEGER,
|
||||
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS node_dimensions (
|
||||
node_id INTEGER NOT NULL,
|
||||
dimension TEXT NOT NULL,
|
||||
PRIMARY KEY (node_id, dimension),
|
||||
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX IF NOT EXISTS idx_dim_by_dimension ON node_dimensions(dimension, node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dim_by_node ON node_dimensions(node_id, dimension);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dimensions (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
is_priority INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contexts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Seed default dimensions
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('research', 1);
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('ideas', 1);
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('projects', 1);
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('memory', 1);
|
||||
INSERT OR IGNORE INTO dimensions (name, is_priority) VALUES ('preferences', 1);
|
||||
`);
|
||||
|
||||
console.error('[RA-H] Database created successfully');
|
||||
} else {
|
||||
db = new Database(dbPath);
|
||||
@@ -123,9 +54,18 @@ function initDatabase() {
|
||||
db.pragma('cache_size = 5000');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
|
||||
ensureCoreSchema(db);
|
||||
|
||||
// Migrations for existing databases
|
||||
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
|
||||
const nodeCols = db.prepare('PRAGMA table_info(nodes)').all().map(c => c.name);
|
||||
if (!nodeCols.includes('source')) {
|
||||
const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(c => c.name);
|
||||
|
||||
let hasSourceColumn = nodeCols.includes('source');
|
||||
if (!hasSourceColumn) {
|
||||
db.exec('ALTER TABLE nodes ADD COLUMN source TEXT;');
|
||||
hasSourceColumn = true;
|
||||
console.error('[RA-H] Migrated nodes: added source column');
|
||||
}
|
||||
if (nodeCols.includes('content')) {
|
||||
db.exec(`
|
||||
@@ -154,22 +94,33 @@ function initDatabase() {
|
||||
AND LENGTH(TRIM(chunk)) > 0;
|
||||
`);
|
||||
}
|
||||
if (!nodeCols.includes('context_id')) {
|
||||
db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
|
||||
if (hasSourceColumn) {
|
||||
db.exec(`
|
||||
UPDATE nodes
|
||||
SET source = title || CASE
|
||||
WHEN description IS NOT NULL AND LENGTH(TRIM(description)) > 0
|
||||
THEN char(10) || char(10) || description
|
||||
ELSE ''
|
||||
END
|
||||
WHERE source IS NULL OR LENGTH(TRIM(source)) = 0;
|
||||
`);
|
||||
}
|
||||
if (!edgeCols.includes('explanation')) {
|
||||
db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
|
||||
try {
|
||||
db.exec(`
|
||||
UPDATE edges SET explanation = json_extract(context, '$.explanation')
|
||||
WHERE explanation IS NULL AND json_extract(context, '$.explanation') IS NOT NULL;
|
||||
`);
|
||||
} catch {}
|
||||
console.error('[RA-H] Migrated edges: added explanation column');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contexts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
if (!nodeCols.includes('context_id')) {
|
||||
db.exec('ALTER TABLE nodes ADD COLUMN context_id INTEGER REFERENCES contexts(id) ON DELETE SET NULL;');
|
||||
console.error('[RA-H] Migrated nodes: added context_id column');
|
||||
}
|
||||
|
||||
const contextCols = db.prepare('PRAGMA table_info(contexts)').all().map(c => c.name);
|
||||
if (!contextCols.includes('description')) {
|
||||
db.exec("ALTER TABLE contexts ADD COLUMN description TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
@@ -182,30 +133,230 @@ function initDatabase() {
|
||||
if (!contextCols.includes('updated_at')) {
|
||||
db.exec("ALTER TABLE contexts ADD COLUMN updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;");
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
UPDATE contexts
|
||||
SET description = COALESCE(NULLIF(TRIM(description), ''), name)
|
||||
WHERE description IS NULL OR LENGTH(TRIM(description)) = 0;
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
|
||||
ON contexts(LOWER(TRIM(name)));
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_context_id ON nodes(context_id);
|
||||
`);
|
||||
|
||||
const edgeCols = db.prepare('PRAGMA table_info(edges)').all().map(c => c.name);
|
||||
if (!edgeCols.includes('explanation')) {
|
||||
db.exec('ALTER TABLE edges ADD COLUMN explanation TEXT;');
|
||||
try {
|
||||
db.exec(`
|
||||
UPDATE edges SET explanation = json_extract(context, '$.explanation')
|
||||
WHERE explanation IS NULL AND json_extract(context, '$.explanation') IS NOT NULL;
|
||||
`);
|
||||
} catch {}
|
||||
console.error('[RA-H] Migrated edges: added explanation column');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dimension_migration_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
migrated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
dimension_count INTEGER NOT NULL,
|
||||
assignment_count INTEGER NOT NULL,
|
||||
payload TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
const hasLegacyDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='dimensions'").get();
|
||||
const hasLegacyNodeDimensions = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_dimensions'").get();
|
||||
if (hasLegacyDimensions || hasLegacyNodeDimensions) {
|
||||
const snapshotCount = Number((db.prepare('SELECT COUNT(*) as count FROM dimension_migration_snapshots').get() || {}).count || 0);
|
||||
|
||||
if (snapshotCount === 0) {
|
||||
const dimensionCount = hasLegacyDimensions
|
||||
? Number((db.prepare('SELECT COUNT(*) as count FROM dimensions').get() || {}).count || 0)
|
||||
: 0;
|
||||
const assignmentCount = hasLegacyNodeDimensions
|
||||
? Number((db.prepare('SELECT COUNT(*) as count FROM node_dimensions').get() || {}).count || 0)
|
||||
: 0;
|
||||
const payload = hasLegacyNodeDimensions
|
||||
? ((db.prepare(`
|
||||
SELECT COALESCE(
|
||||
json_group_array(
|
||||
json_object(
|
||||
'node_id', nd.node_id,
|
||||
'dimension', nd.dimension,
|
||||
'description', d.description,
|
||||
'icon', d.icon,
|
||||
'is_priority', d.is_priority
|
||||
)
|
||||
),
|
||||
'[]'
|
||||
) AS payload
|
||||
FROM node_dimensions nd
|
||||
LEFT JOIN dimensions d ON d.name = nd.dimension
|
||||
`).get() || {}).payload || '[]')
|
||||
: '[]';
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO dimension_migration_snapshots (dimension_count, assignment_count, payload)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(dimensionCount, assignmentCount, payload);
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
DROP INDEX IF EXISTS idx_dim_by_dimension;
|
||||
DROP INDEX IF EXISTS idx_dim_by_node;
|
||||
DROP TABLE IF EXISTS node_dimensions;
|
||||
DROP TABLE IF EXISTS dimensions;
|
||||
`);
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function ensureCoreSchema(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contexts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
source TEXT,
|
||||
link TEXT,
|
||||
event_date TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT,
|
||||
embedding BLOB,
|
||||
embedding_updated_at TEXT,
|
||||
embedding_text TEXT,
|
||||
chunk_status TEXT DEFAULT 'not_chunked',
|
||||
context_id INTEGER,
|
||||
FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contexts_name_normalized
|
||||
ON contexts(LOWER(TRIM(name)));
|
||||
`);
|
||||
|
||||
ensureEdgesTableSchema(db);
|
||||
}
|
||||
|
||||
function ensureEdgesTableSchema(db) {
|
||||
const hasEdgesTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='edges'").get();
|
||||
|
||||
if (!hasEdgesTable) {
|
||||
db.exec(`
|
||||
CREATE TABLE edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} else {
|
||||
const edgeColNames = new Set(db.prepare('PRAGMA table_info(edges)').all().map((col) => col.name));
|
||||
const needsLegacyRewrite =
|
||||
!edgeColNames.has('from_node_id') ||
|
||||
!edgeColNames.has('to_node_id') ||
|
||||
!edgeColNames.has('source') ||
|
||||
!edgeColNames.has('created_at') ||
|
||||
!edgeColNames.has('context') ||
|
||||
edgeColNames.has('from_id') ||
|
||||
edgeColNames.has('to_id') ||
|
||||
edgeColNames.has('description') ||
|
||||
edgeColNames.has('updated_at');
|
||||
|
||||
if (needsLegacyRewrite) {
|
||||
rebuildLegacyEdgesTable(db, edgeColNames);
|
||||
}
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_node_id);
|
||||
`);
|
||||
}
|
||||
|
||||
function rebuildLegacyEdgesTable(db, edgeColNames) {
|
||||
const fromExpr = edgeColNames.has('from_node_id')
|
||||
? 'from_node_id'
|
||||
: edgeColNames.has('from_id')
|
||||
? 'from_id'
|
||||
: 'NULL';
|
||||
const toExpr = edgeColNames.has('to_node_id')
|
||||
? 'to_node_id'
|
||||
: edgeColNames.has('to_id')
|
||||
? 'to_id'
|
||||
: 'NULL';
|
||||
const sourceExpr = edgeColNames.has('source') ? 'source' : "'legacy'";
|
||||
const createdAtExpr = edgeColNames.has('created_at') ? 'created_at' : 'CURRENT_TIMESTAMP';
|
||||
const contextExpr = edgeColNames.has('context') ? 'context' : 'NULL';
|
||||
const explanationExpr = edgeColNames.has('explanation')
|
||||
? 'explanation'
|
||||
: edgeColNames.has('description')
|
||||
? 'description'
|
||||
: edgeColNames.has('context')
|
||||
? "CASE WHEN json_valid(context) THEN json_extract(context, '$.explanation') ELSE NULL END"
|
||||
: 'NULL';
|
||||
|
||||
console.error('[RA-H] Migrating legacy edges table to canonical schema');
|
||||
|
||||
let flippedForeignKeys = false;
|
||||
try {
|
||||
db.exec('PRAGMA foreign_keys=OFF;');
|
||||
flippedForeignKeys = true;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
db.exec('BEGIN TRANSACTION;');
|
||||
db.exec(`
|
||||
DROP INDEX IF EXISTS idx_edges_from;
|
||||
DROP INDEX IF EXISTS idx_edges_to;
|
||||
ALTER TABLE edges RENAME TO edges_legacy_migration;
|
||||
CREATE TABLE edges (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_node_id INTEGER NOT NULL,
|
||||
to_node_id INTEGER NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
context TEXT,
|
||||
explanation TEXT,
|
||||
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO edges (id, from_node_id, to_node_id, source, created_at, context, explanation)
|
||||
SELECT
|
||||
id,
|
||||
${fromExpr},
|
||||
${toExpr},
|
||||
${sourceExpr},
|
||||
COALESCE(${createdAtExpr}, CURRENT_TIMESTAMP),
|
||||
${contextExpr},
|
||||
${explanationExpr}
|
||||
FROM edges_legacy_migration
|
||||
WHERE ${fromExpr} IS NOT NULL
|
||||
AND ${toExpr} IS NOT NULL;
|
||||
DROP TABLE edges_legacy_migration;
|
||||
COMMIT;
|
||||
`);
|
||||
} catch (error) {
|
||||
try {
|
||||
db.exec('ROLLBACK;');
|
||||
} catch {}
|
||||
throw error;
|
||||
} finally {
|
||||
if (flippedForeignKeys) {
|
||||
try {
|
||||
db.exec('PRAGMA foreign_keys=ON;');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database instance.
|
||||
* Throws if not initialized.
|
||||
|
||||
Reference in New Issue
Block a user