UNPKG

aiwf

Version:

AI Workflow Framework for Claude Code with multi-language support (Korean/English)

455 lines (395 loc) 14 kB
import { TokenCounter } from './token-counter.js'; /** * 콘텐츠 중복 제거 및 정규화 유틸리티 */ export class ContentNormalizer { constructor() { this.tokenCounter = new TokenCounter(); } /** * 콘텐츠를 정규화합니다 * @param {string} content - 정규화할 콘텐츠 * @param {Object} options - 정규화 옵션 * @returns {Object} 정규화 결과 */ normalize(content, options = {}) { const { removeDuplicates = true, normalizeWhitespace = true, normalizeMarkdown = true, normalizeLinks = true, normalizeHeaders = true, preserveStructure = true } = options; let normalized = content; const metadata = { operations: [], originalTokens: this.tokenCounter.countTokens(content), removedDuplicates: 0, normalizedElements: 0 }; // 1. 중복 제거 if (removeDuplicates) { const duplicateResult = this.removeDuplicates(normalized); normalized = duplicateResult.content; metadata.removedDuplicates = duplicateResult.removedCount; metadata.operations.push('duplicate_removal'); } // 2. 공백 정규화 if (normalizeWhitespace) { normalized = this.normalizeWhitespace(normalized); metadata.operations.push('whitespace_normalization'); } // 3. 마크다운 정규화 if (normalizeMarkdown) { normalized = this.normalizeMarkdown(normalized); metadata.operations.push('markdown_normalization'); } // 4. 링크 정규화 if (normalizeLinks) { normalized = this.normalizeLinks(normalized); metadata.operations.push('link_normalization'); } // 5. 헤더 정규화 if (normalizeHeaders) { normalized = this.normalizeHeaders(normalized); metadata.operations.push('header_normalization'); } // 6. 구조 보존 if (preserveStructure) { normalized = this.preserveStructure(normalized); metadata.operations.push('structure_preservation'); } metadata.normalizedTokens = this.tokenCounter.countTokens(normalized); metadata.compressionRatio = this.calculateCompressionRatio(content, normalized); return { original: content, normalized, metadata }; } /** * 중복 콘텐츠를 제거합니다 * @param {string} content - 콘텐츠 * @returns {Object} 중복 제거 결과 */ removeDuplicates(content) { const lines = content.split('\n'); const uniqueLines = []; const seenLines = new Set(); let removedCount = 0; // 1. 완전히 동일한 라인 제거 for (const line of lines) { const normalizedLine = this.normalizeLineForComparison(line); if (normalizedLine.trim() === '') { uniqueLines.push(line); // 빈 라인은 유지 } else if (!seenLines.has(normalizedLine)) { seenLines.add(normalizedLine); uniqueLines.push(line); } else { removedCount++; } } // 2. 비슷한 헤더 제거 const dedupedHeaders = this.removeDuplicateHeaders(uniqueLines.join('\n')); const headerRemovedCount = uniqueLines.length - dedupedHeaders.split('\n').length; removedCount += headerRemovedCount; // 3. 중복 목록 항목 제거 const dedupedLists = this.removeDuplicateListItems(dedupedHeaders); const listRemovedCount = dedupedHeaders.split('\n').length - dedupedLists.split('\n').length; removedCount += listRemovedCount; // 4. 반복되는 패턴 제거 const dedupedPatterns = this.removeRepeatingPatterns(dedupedLists); const patternRemovedCount = dedupedLists.split('\n').length - dedupedPatterns.split('\n').length; removedCount += patternRemovedCount; return { content: dedupedPatterns, removedCount }; } /** * 비교를 위해 라인을 정규화합니다 * @param {string} line - 라인 * @returns {string} 정규화된 라인 */ normalizeLineForComparison(line) { return line .trim() .toLowerCase() .replace(/\s+/g, ' ') .replace(/[^\w\s가-힣]/g, ''); } /** * 중복 헤더를 제거합니다 * @param {string} content - 콘텐츠 * @returns {string} 중복 헤더가 제거된 콘텐츠 */ removeDuplicateHeaders(content) { const lines = content.split('\n'); const filteredLines = []; const seenHeaders = new Set(); for (const line of lines) { const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headerMatch) { const headerLevel = headerMatch[1].length; const headerText = headerMatch[2].trim().toLowerCase(); const headerKey = `${headerLevel}:${headerText}`; if (!seenHeaders.has(headerKey)) { seenHeaders.add(headerKey); filteredLines.push(line); } } else { filteredLines.push(line); } } return filteredLines.join('\n'); } /** * 중복 목록 항목을 제거합니다 * @param {string} content - 콘텐츠 * @returns {string} 중복 목록 항목이 제거된 콘텐츠 */ removeDuplicateListItems(content) { const lines = content.split('\n'); const filteredLines = []; const seenListItems = new Set(); for (const line of lines) { const listMatch = line.match(/^\s*([-*+]|\d+\.)\s+(.+)$/); if (listMatch) { const itemText = listMatch[2].trim().toLowerCase(); if (!seenListItems.has(itemText)) { seenListItems.add(itemText); filteredLines.push(line); } } else { filteredLines.push(line); } } return filteredLines.join('\n'); } /** * 반복되는 패턴을 제거합니다 * @param {string} content - 콘텐츠 * @returns {string} 반복 패턴이 제거된 콘텐츠 */ removeRepeatingPatterns(content) { let processed = content; // 1. 연속된 동일한 문자열 제거 processed = processed.replace(/(.{10,})\1{2,}/g, '$1'); // 2. 반복되는 구분선 제거 processed = processed.replace(/(-{3,})\n\1(\n\1)*/g, '$1'); // 3. 반복되는 빈 줄 제거 processed = processed.replace(/\n{4,}/g, '\n\n\n'); // 4. 반복되는 마크다운 태그 제거 processed = processed.replace(/(\*{1,3})\s*\1+/g, '$1'); return processed; } /** * 공백을 정규화합니다 * @param {string} content - 콘텐츠 * @returns {string} 공백이 정규화된 콘텐츠 */ normalizeWhitespace(content) { return content // 탭을 스페이스로 변환 .replace(/\t/g, ' ') // 줄 끝 공백 제거 .replace(/[ \t]+$/gm, '') // 연속된 스페이스를 하나로 변환 (마크다운 구조 유지) .replace(/([^\n]) {3,}/g, '$1 ') // 과도한 빈 줄 제거 .replace(/\n{4,}/g, '\n\n\n') // 시작과 끝의 불필요한 공백 제거 .replace(/^\s+|\s+$/g, ''); } /** * 마크다운을 정규화합니다 * @param {string} content - 콘텐츠 * @returns {string} 마크다운이 정규화된 콘텐츠 */ normalizeMarkdown(content) { return content // 헤더 스타일 통일 .replace(/^(#{1,6})\s*(.+)\s*$/gm, '$1 $2') // 목록 스타일 통일 .replace(/^\s*[*+]\s+/gm, '- ') // 강조 스타일 통일 .replace(/\*\*([^*]+)\*\*/g, '**$1**') .replace(/\*([^*]+)\*/g, '*$1*') // 코드 블록 스타일 통일 .replace(/```([^`]*?)```/gs, '```$1```') // 인라인 코드 스타일 통일 .replace(/`([^`]+)`/g, '`$1`') // 인용구 스타일 통일 .replace(/^\s*>\s*/gm, '> ') // 수평선 스타일 통일 .replace(/^\s*[-*_]{3,}\s*$/gm, '---'); } /** * 링크를 정규화합니다 * @param {string} content - 콘텐츠 * @returns {string} 링크가 정규화된 콘텐츠 */ normalizeLinks(content) { // 1. 중복 링크 제거 const linkMap = new Map(); let linkCounter = 0; return content // 마크다운 링크 정규화 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { const normalizedUrl = url.trim(); if (linkMap.has(normalizedUrl)) { return linkMap.get(normalizedUrl); } else { linkCounter++; const linkRef = `[${text}][${linkCounter}]`; linkMap.set(normalizedUrl, linkRef); return linkRef; } }) // 링크 참조 섹션 추가 + (linkMap.size > 0 ? '\n\n' + Array.from(linkMap.entries()) .map(([url], index) => `[${index + 1}]: ${url}`) .join('\n') : ''); } /** * 헤더를 정규화합니다 * @param {string} content - 콘텐츠 * @returns {string} 헤더가 정규화된 콘텐츠 */ normalizeHeaders(content) { const lines = content.split('\n'); const normalizedLines = []; const headerHierarchy = new Map(); for (const line of lines) { const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headerMatch) { const level = headerMatch[1].length; const text = headerMatch[2].trim(); // 헤더 계층 구조 정규화 if (level > 1) { let parentLevel = level - 1; while (parentLevel > 0 && !headerHierarchy.has(parentLevel)) { parentLevel--; } if (parentLevel === 0 && level > 2) { // 부모 헤더가 없는 경우 레벨 조정 const adjustedLevel = Math.max(1, level - 1); normalizedLines.push(`${'#'.repeat(adjustedLevel)} ${text}`); headerHierarchy.set(adjustedLevel, text); } else { normalizedLines.push(line); headerHierarchy.set(level, text); } } else { normalizedLines.push(line); headerHierarchy.set(level, text); } } else { normalizedLines.push(line); } } return normalizedLines.join('\n'); } /** * 구조를 보존합니다 * @param {string} content - 콘텐츠 * @returns {string} 구조가 보존된 콘텐츠 */ preserveStructure(content) { const lines = content.split('\n'); const preservedLines = []; let inCodeBlock = false; let codeBlockType = ''; for (const line of lines) { // 코드 블록 감지 if (line.startsWith('```')) { if (!inCodeBlock) { inCodeBlock = true; codeBlockType = line.substring(3); } else if (inCodeBlock && line === '```') { inCodeBlock = false; codeBlockType = ''; } } // 코드 블록 내부는 그대로 유지 if (inCodeBlock) { preservedLines.push(line); } else { // 일반 텍스트 처리 if (line.trim() === '') { // 빈 줄은 컨텍스트에 따라 처리 const lastLine = preservedLines[preservedLines.length - 1]; const nextLineIndex = lines.indexOf(line) + 1; const nextLine = nextLineIndex < lines.length ? lines[nextLineIndex] : ''; // 헤더 전후, 목록 전후에는 빈 줄 유지 if (lastLine && (lastLine.match(/^#{1,6}\s+/) || lastLine.match(/^\s*[-*+]\s+/)) || nextLine.match(/^#{1,6}\s+/) || nextLine.match(/^\s*[-*+]\s+/)) { preservedLines.push(line); } else if (preservedLines.length > 0 && preservedLines[preservedLines.length - 1] !== '') { preservedLines.push(line); } } else { preservedLines.push(line); } } } return preservedLines.join('\n'); } /** * 텍스트 유사성을 계산합니다 * @param {string} text1 - 첫 번째 텍스트 * @param {string} text2 - 두 번째 텍스트 * @returns {number} 유사성 점수 (0-1) */ calculateTextSimilarity(text1, text2) { const words1 = new Set(text1.toLowerCase().split(/\s+/)); const words2 = new Set(text2.toLowerCase().split(/\s+/)); const intersection = new Set([...words1].filter(word => words2.has(word))); const union = new Set([...words1, ...words2]); return intersection.size / union.size; } /** * 압축률을 계산합니다 * @param {string} original - 원본 콘텐츠 * @param {string} normalized - 정규화된 콘텐츠 * @returns {number} 압축률 (백분율) */ calculateCompressionRatio(original, normalized) { const originalTokens = this.tokenCounter.countTokens(original); const normalizedTokens = this.tokenCounter.countTokens(normalized); if (originalTokens === 0) return 0; return ((originalTokens - normalizedTokens) / originalTokens) * 100; } /** * 정규화 통계를 생성합니다 * @param {string} original - 원본 콘텐츠 * @param {string} normalized - 정규화된 콘텐츠 * @returns {Object} 통계 정보 */ generateStats(original, normalized) { const originalLines = original.split('\n'); const normalizedLines = normalized.split('\n'); return { originalLines: originalLines.length, normalizedLines: normalizedLines.length, linesRemoved: originalLines.length - normalizedLines.length, originalTokens: this.tokenCounter.countTokens(original), normalizedTokens: this.tokenCounter.countTokens(normalized), tokensReduced: this.tokenCounter.countTokens(original) - this.tokenCounter.countTokens(normalized), compressionRatio: this.calculateCompressionRatio(original, normalized), originalSize: Buffer.byteLength(original, 'utf8'), normalizedSize: Buffer.byteLength(normalized, 'utf8'), sizeReduction: Buffer.byteLength(original, 'utf8') - Buffer.byteLength(normalized, 'utf8') }; } /** * 리소스를 정리합니다 */ cleanup() { if (this.tokenCounter) { this.tokenCounter.cleanup(); } } } export default ContentNormalizer;