/** * 類似度計算モジュール * * TSK-009: 類似度計算モジュール * REQ-KM-003: 関連プロジェクトの自動検出 * DES-KM-003: ナレッジ連携システム設計 */ import type { SimilarityResult, FreshnessResult } from './types.js'; /** * 2つのキーワードセット間の類似度を計算 * Jaccard係数 + 部分一致ボーナス */ export function calculateSimilarity( keywords1: string[], keywords2: string[] ): SimilarityResult { if (keywords1.length === 0 || keywords2.length === 0) { return { jaccardIndex: 0, matchCount: 0, totalKeywords: Math.max(keywords1.length, keywords2.length), finalScore: 0, }; } // 正規化(小文字化) const set1 = new Set(keywords1.map((k) => k.toLowerCase())); const set2 = new Set(keywords2.map((k) => k.toLowerCase())); // 完全一致を計算 const intersection = new Set([...set1].filter((k) => set2.has(k))); const union = new Set([...set1, ...set2]); const jaccardIndex = intersection.size / union.size; // 部分一致ボーナスを計算 let partialMatchBonus = 0; for (const k1 of set1) { for (const k2 of set2) { if (k1 !== k2) { // 一方が他方を含む場合 if (k1.includes(k2) || k2.includes(k1)) { partialMatchBonus += 0.1; } } } } // 最終スコア = Jaccard + 部分一致ボーナス(上限1.0) const finalScore = Math.min(1.0, jaccardIndex + partialMatchBonus); return { jaccardIndex, matchCount: intersection.size, totalKeywords: union.size, finalScore, }; } /** * プロジェクトの鮮度をチェック */ export function checkFreshness( updatedAt: Date, warningDays: number = 30, staleDays: number = 90 ): FreshnessResult { const now = new Date(); const diffMs = now.getTime() - updatedAt.getTime(); const daysSinceUpdate = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (daysSinceUpdate <= warningDays) { return { status: 'fresh', daysSinceUpdate, }; } if (daysSinceUpdate <= staleDays) { return { status: 'warning', daysSinceUpdate, message: `${daysSinceUpdate}日前に更新(30日以上経過)`, }; } return { status: 'stale', daysSinceUpdate, message: `${daysSinceUpdate}日前に更新(90日以上経過 - 古いデータの可能性)`, }; } /** * キーワードの重みを計算 * 専門用語や長い語句は重要度が高い */ export function calculateKeywordWeight(keyword: string): number { let weight = 1.0; // 長さボーナス(4文字以上) if (keyword.length >= 4) { weight += 0.2; } if (keyword.length >= 8) { weight += 0.2; } // 英語を含む場合(専門用語の可能性) if (/[a-zA-Z]/.test(keyword)) { weight += 0.3; } // 漢字を含む場合(専門用語の可能性) if (/[\u4e00-\u9faf]/.test(keyword)) { weight += 0.2; } return weight; } /** * 重み付き類似度を計算 */ export function calculateWeightedSimilarity( keywords1: string[], keywords2: string[] ): number { if (keywords1.length === 0 || keywords2.length === 0) { return 0; } // 各キーワードの重みを計算 const weights1 = new Map(keywords1.map((k) => [k.toLowerCase(), calculateKeywordWeight(k)])); const weights2 = new Map(keywords2.map((k) => [k.toLowerCase(), calculateKeywordWeight(k)])); // マッチしたキーワードの重み合計 let matchedWeight = 0; let totalWeight = 0; for (const [keyword, weight] of weights1) { totalWeight += weight; if (weights2.has(keyword)) { matchedWeight += weight; } } for (const [keyword, weight] of weights2) { if (!weights1.has(keyword)) { totalWeight += weight; } } return totalWeight > 0 ? matchedWeight / totalWeight : 0; }