/** * KnowledgeDiscoveryService * * TSK-010: KnowledgeDiscoveryService * REQ-KM-003: 関連プロジェクトの自動検出 * DES-KM-003: ナレッジ連携システム設計 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as yaml from 'yaml'; import type { ProjectMeta, ProjectIndex, RelatedProject, DiscoveryOptions, } from './types.js'; import { calculateSimilarity, calculateWeightedSimilarity, checkFreshness, } from './similarity.js'; /** * デフォルト設定 */ const DEFAULT_OPTIONS: Required = { keywords: [], threshold: 0.3, maxResults: 5, freshnessWarningDays: 30, }; /** * ナレッジ連携サービス * * projects/配下のプロジェクトをスキャンし、関連プロジェクトを検出する */ export class KnowledgeDiscoveryService { private readonly baseDir: string; private readonly indexFileName: string; private indexCache: ProjectIndex | null = null; constructor(baseDir: string = 'projects', indexFileName: string = '.shikigami-index.json') { this.baseDir = baseDir; this.indexFileName = indexFileName; } /** * 関連プロジェクトを検索 */ async findRelatedProjects(options: DiscoveryOptions = {}): Promise { const opts = { ...DEFAULT_OPTIONS, ...options }; // インデックスを読み込み(なければスキャン) const index = await this.loadOrCreateIndex(); if (index.projects.length === 0) { return []; } // キーワードが指定されていない場合は全プロジェクトを返す if (opts.keywords.length === 0) { return index.projects .map((project) => this.createRelatedProject(project, opts.keywords, opts.freshnessWarningDays)) .filter((rp) => rp !== null) as RelatedProject[]; } // 各プロジェクトとの類似度を計算 const results: RelatedProject[] = []; for (const project of index.projects) { const similarity = calculateWeightedSimilarity(opts.keywords, project.keywords); if (similarity >= opts.threshold) { const relatedProject = this.createRelatedProject( project, opts.keywords, opts.freshnessWarningDays, similarity ); if (relatedProject) { results.push(relatedProject); } } } // スコア順にソートして上位を返す results.sort((a, b) => b.similarityScore - a.similarityScore); return results.slice(0, opts.maxResults); } /** * RelatedProjectオブジェクトを作成 */ private createRelatedProject( project: ProjectMeta, searchKeywords: string[], freshnessWarningDays: number, similarity?: number ): RelatedProject | null { const similarityResult = similarity !== undefined ? { finalScore: similarity, matchCount: 0, jaccardIndex: 0, totalKeywords: 0 } : calculateSimilarity(searchKeywords, project.keywords); const freshness = checkFreshness(new Date(project.updatedAt), freshnessWarningDays); // マッチしたキーワードを特定 const searchSet = new Set(searchKeywords.map((k) => k.toLowerCase())); const matchedKeywords = project.keywords.filter((k) => searchSet.has(k.toLowerCase())); return { project, similarityScore: similarityResult.finalScore, matchedKeywords, freshness: freshness.status, freshnessMessage: freshness.message, }; } /** * インデックスを読み込むか、なければスキャンして作成 */ private async loadOrCreateIndex(): Promise { // キャッシュがあれば使用 if (this.indexCache) { return this.indexCache; } const indexPath = path.join(this.baseDir, this.indexFileName); // インデックスファイルが存在すれば読み込み if (fs.existsSync(indexPath)) { try { const content = fs.readFileSync(indexPath, 'utf-8'); const index = JSON.parse(content) as ProjectIndex; // 日付をDateオブジェクトに変換 index.projects = index.projects.map((p) => ({ ...p, createdAt: new Date(p.createdAt), updatedAt: new Date(p.updatedAt), })); index.lastUpdated = new Date(index.lastUpdated); this.indexCache = index; return index; } catch { // 読み込み失敗時はスキャン } } // スキャンしてインデックス作成 const index = await this.scanProjects(); await this.saveIndex(index); this.indexCache = index; return index; } /** * プロジェクトをスキャン */ async scanProjects(): Promise { const projects: ProjectMeta[] = []; if (!fs.existsSync(this.baseDir)) { return { version: '1.0', lastUpdated: new Date(), projects: [], }; } const entries = fs.readdirSync(this.baseDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) { continue; } // 隠しディレクトリをスキップ if (entry.name.startsWith('.')) { continue; } const projectPath = path.join(this.baseDir, entry.name); const meta = await this.extractProjectMeta(projectPath, entry.name); if (meta) { projects.push(meta); } } return { version: '1.0', lastUpdated: new Date(), projects, }; } /** * プロジェクトメタデータを抽出 */ private async extractProjectMeta( projectPath: string, projectName: string ): Promise { const manifestPath = path.join(projectPath, 'manifest.yaml'); const manifestYmlPath = path.join(projectPath, 'manifest.yml'); let keywords: string[] = []; let description: string | undefined; let tags: string[] | undefined; let status: 'active' | 'completed' | 'archived' = 'active'; // manifest.yaml を読み込み const manifestFile = fs.existsSync(manifestPath) ? manifestPath : fs.existsSync(manifestYmlPath) ? manifestYmlPath : null; if (manifestFile) { try { const content = fs.readFileSync(manifestFile, 'utf-8'); const manifest = yaml.parse(content) as Record; keywords = (manifest.keywords as string[]) || []; description = manifest.description as string | undefined; tags = manifest.tags as string[] | undefined; status = (manifest.status as 'active' | 'completed' | 'archived') || 'active'; // プロジェクト名から追加キーワードを抽出 const nameKeywords = this.extractKeywordsFromName(projectName); keywords = [...new Set([...keywords, ...nameKeywords])]; } catch { // manifest読み込み失敗時はプロジェクト名からキーワード抽出 keywords = this.extractKeywordsFromName(projectName); } } else { // manifestがない場合はプロジェクト名からキーワード抽出 keywords = this.extractKeywordsFromName(projectName); } // ディレクトリの作成日時・更新日時を取得 const stats = fs.statSync(projectPath); return { name: projectName, path: projectPath, keywords, createdAt: stats.birthtime, updatedAt: stats.mtime, status, description, tags, }; } /** * プロジェクト名からキーワードを抽出 */ private extractKeywordsFromName(name: string): string[] { // ハイフン、アンダースコア、キャメルケースで分割 const words = name .split(/[-_]/) .flatMap((w) => w.split(/(?=[A-Z])/)) .map((w) => w.toLowerCase()) .filter((w) => w.length >= 2); return [...new Set(words)]; } /** * インデックスを保存 */ async saveIndex(index: ProjectIndex): Promise { const indexPath = path.join(this.baseDir, this.indexFileName); // ディレクトリがなければ作成 if (!fs.existsSync(this.baseDir)) { fs.mkdirSync(this.baseDir, { recursive: true }); } const content = JSON.stringify(index, null, 2); fs.writeFileSync(indexPath, content, 'utf-8'); } /** * インデックスを再構築 */ async rebuildIndex(): Promise { this.indexCache = null; const index = await this.scanProjects(); await this.saveIndex(index); this.indexCache = index; return index; } /** * キャッシュをクリア */ clearCache(): void { this.indexCache = null; } } // エクスポート export * from './types.js'; export * from './similarity.js';