212 lines
6.2 KiB
JavaScript
212 lines
6.2 KiB
JavaScript
|
|
/**
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
* MCP 工具 · 结构操作(buildPath / scanStructure / classify)
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*
|
|||
|
|
* 签发: 铸渊 · ICE-GL-ZY001
|
|||
|
|
* 版权: 国作登字-2026-A-00037559
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const { v4: uuidv4 } = require('uuid');
|
|||
|
|
const db = require('../db');
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* buildPath — 批量创建路径结构(类似 mkdir -p)
|
|||
|
|
* 路径中已存在的节点跳过,不存在的自动创建为 folder 类型
|
|||
|
|
*/
|
|||
|
|
async function buildPath(input) {
|
|||
|
|
const { path: targetPath, owner } = input;
|
|||
|
|
const segments = targetPath.split('/').filter(s => s.length > 0);
|
|||
|
|
const createdNodes = [];
|
|||
|
|
let parentId = null;
|
|||
|
|
let currentPath = '';
|
|||
|
|
|
|||
|
|
for (const segment of segments) {
|
|||
|
|
currentPath += '/' + segment;
|
|||
|
|
|
|||
|
|
// 检查此路径是否已存在
|
|||
|
|
const existing = await db.query(
|
|||
|
|
'SELECT id FROM brain_nodes WHERE path = $1 AND status != $2',
|
|||
|
|
[currentPath, 'deleted']
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (existing.rows.length > 0) {
|
|||
|
|
parentId = existing.rows[0].id;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 创建 folder 节点
|
|||
|
|
const id = uuidv4();
|
|||
|
|
await db.query(
|
|||
|
|
`INSERT INTO brain_nodes (id, title, node_type, parent_id, path, tags, source, owner, status, created_by)
|
|||
|
|
VALUES ($1, $2, 'folder', $3, $4, '[]'::jsonb, 'agent', $5, 'active', $6)`,
|
|||
|
|
[id, segment, parentId, currentPath, owner, 'buildPath']
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
createdNodes.push({ id, title: segment, path: currentPath });
|
|||
|
|
parentId = id;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
created_nodes: createdNodes,
|
|||
|
|
leaf_node_id: parentId
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* scanStructure — 扫描大脑结构健康度
|
|||
|
|
*/
|
|||
|
|
async function scanStructure(input) {
|
|||
|
|
const rootPath = input.root_path || null;
|
|||
|
|
const checks = input.checks || ['orphans', 'broken_links', 'duplicates', 'empty_folders'];
|
|||
|
|
const result = {
|
|||
|
|
total_nodes: 0,
|
|||
|
|
orphan_nodes: [],
|
|||
|
|
broken_links: [],
|
|||
|
|
duplicate_titles: [],
|
|||
|
|
empty_folders: [],
|
|||
|
|
health_score: 100
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 总节点数
|
|||
|
|
const countResult = await db.query(
|
|||
|
|
"SELECT COUNT(*) as total FROM brain_nodes WHERE status = 'active'"
|
|||
|
|
);
|
|||
|
|
result.total_nodes = parseInt(countResult.rows[0].total, 10);
|
|||
|
|
|
|||
|
|
if (result.total_nodes === 0) {
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 孤岛节点:有parent_id但parent不存在
|
|||
|
|
if (checks.includes('orphans')) {
|
|||
|
|
const orphans = await db.query(
|
|||
|
|
`SELECT n.id, n.title, n.path, n.parent_id
|
|||
|
|
FROM brain_nodes n
|
|||
|
|
LEFT JOIN brain_nodes p ON n.parent_id = p.id
|
|||
|
|
WHERE n.parent_id IS NOT NULL
|
|||
|
|
AND n.status = 'active'
|
|||
|
|
AND (p.id IS NULL OR p.status = 'deleted')`
|
|||
|
|
);
|
|||
|
|
result.orphan_nodes = orphans.rows;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 断链:关系指向不存在的节点
|
|||
|
|
if (checks.includes('broken_links')) {
|
|||
|
|
const broken = await db.query(
|
|||
|
|
`SELECT r.*
|
|||
|
|
FROM brain_relations r
|
|||
|
|
LEFT JOIN brain_nodes fn ON r.from_node_id = fn.id AND fn.status = 'active'
|
|||
|
|
LEFT JOIN brain_nodes tn ON r.to_node_id = tn.id AND tn.status = 'active'
|
|||
|
|
WHERE fn.id IS NULL OR tn.id IS NULL`
|
|||
|
|
);
|
|||
|
|
result.broken_links = broken.rows;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重复标题
|
|||
|
|
if (checks.includes('duplicates')) {
|
|||
|
|
const dupes = await db.query(
|
|||
|
|
`SELECT title, array_agg(id) as node_ids, COUNT(*) as cnt
|
|||
|
|
FROM brain_nodes
|
|||
|
|
WHERE status = 'active'
|
|||
|
|
GROUP BY title
|
|||
|
|
HAVING COUNT(*) > 1
|
|||
|
|
ORDER BY cnt DESC
|
|||
|
|
LIMIT 20`
|
|||
|
|
);
|
|||
|
|
result.duplicate_titles = dupes.rows;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 空目录
|
|||
|
|
if (checks.includes('empty_folders')) {
|
|||
|
|
const empty = await db.query(
|
|||
|
|
`SELECT f.id, f.title, f.path
|
|||
|
|
FROM brain_nodes f
|
|||
|
|
LEFT JOIN brain_nodes c ON c.parent_id = f.id AND c.status = 'active'
|
|||
|
|
WHERE f.node_type = 'folder'
|
|||
|
|
AND f.status = 'active'
|
|||
|
|
AND c.id IS NULL`
|
|||
|
|
);
|
|||
|
|
result.empty_folders = empty.rows;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算健康分
|
|||
|
|
const issues = result.orphan_nodes.length + result.broken_links.length +
|
|||
|
|
result.duplicate_titles.length + result.empty_folders.length;
|
|||
|
|
result.health_score = Math.max(0, Math.round(100 - (issues / result.total_nodes) * 100));
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* classify — 自动分类/打标签
|
|||
|
|
* strategy=rule: 纯关键词匹配,零成本
|
|||
|
|
* strategy=model: 调用DeepSeek(极少使用)
|
|||
|
|
*/
|
|||
|
|
async function classify(input) {
|
|||
|
|
const { node_ids, strategy, rules, dry_run } = input;
|
|||
|
|
const classified = [];
|
|||
|
|
const unclassified = [];
|
|||
|
|
|
|||
|
|
for (const nodeId of node_ids) {
|
|||
|
|
const nodeResult = await db.query(
|
|||
|
|
'SELECT id, title, summary, tags, path FROM brain_nodes WHERE id = $1',
|
|||
|
|
[nodeId]
|
|||
|
|
);
|
|||
|
|
if (nodeResult.rows.length === 0) continue;
|
|||
|
|
|
|||
|
|
const node = nodeResult.rows[0];
|
|||
|
|
|
|||
|
|
if (strategy === 'rule' && rules) {
|
|||
|
|
// 规则匹配
|
|||
|
|
const matchResult = applyRules(node, rules);
|
|||
|
|
if (matchResult) {
|
|||
|
|
classified.push({
|
|||
|
|
node_id: nodeId,
|
|||
|
|
assigned_tags: matchResult.tags,
|
|||
|
|
assigned_path: matchResult.path
|
|||
|
|
});
|
|||
|
|
if (!dry_run) {
|
|||
|
|
// 实际更新
|
|||
|
|
const newTags = [...new Set([...(node.tags || []), ...matchResult.tags])];
|
|||
|
|
await db.query(
|
|||
|
|
'UPDATE brain_nodes SET tags = $1, path = COALESCE($2, path) WHERE id = $3',
|
|||
|
|
[JSON.stringify(newTags), matchResult.path || null, nodeId]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
unclassified.push(nodeId);
|
|||
|
|
}
|
|||
|
|
} else if (strategy === 'model') {
|
|||
|
|
// 模型分类 — 占位,S5阶段实现
|
|||
|
|
unclassified.push(nodeId);
|
|||
|
|
} else {
|
|||
|
|
unclassified.push(nodeId);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { classified, unclassified };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 规则引擎:关键词→标签映射
|
|||
|
|
*/
|
|||
|
|
function applyRules(node, rules) {
|
|||
|
|
const text = ((node.title || '') + ' ' + (node.summary || '')).toLowerCase();
|
|||
|
|
|
|||
|
|
for (const [pattern, result] of Object.entries(rules)) {
|
|||
|
|
const keywords = pattern.toLowerCase().split('|');
|
|||
|
|
if (keywords.some(kw => text.includes(kw))) {
|
|||
|
|
return {
|
|||
|
|
tags: Array.isArray(result.tags) ? result.tags : [result.tags],
|
|||
|
|
path: result.path || null
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = { buildPath, scanStructure, classify };
|