import * as fs from 'fs'; import * as path from 'path'; import { findBrainFiles, summarizeText } from '../utils'; export interface SecondBrainTraceDocument { title: string; path: string; absolutePath: string; score: number; excerpt: string; usedInAnswer: boolean; usedFor?: string; excludedReason?: string; } export interface SecondBrainTrace { userQuery: string; shouldUseSecondBrain: boolean; secondBrainUsed: boolean; reason: string; retrievalQuery: string; searchedCollections: string[]; retrievedDocuments: SecondBrainTraceDocument[]; groundingScore: number; } export function buildSecondBrainTrace(userQuery: string, brainRoot: string, options: { force?: boolean; limit?: number; } = {}): SecondBrainTrace { const query = userQuery.trim(); const shouldUseSecondBrain = !!options.force || shouldUseBrain(query); const retrievalQuery = buildRetrievalQuery(query); const baseTrace: SecondBrainTrace = { userQuery: query, shouldUseSecondBrain, secondBrainUsed: false, reason: shouldUseSecondBrain ? 'Project-specific or memory-sensitive information may be needed.' : 'This looks answerable without project-specific Second Brain context.', retrievalQuery, searchedCollections: [], retrievedDocuments: [], groundingScore: 0 }; if (!shouldUseSecondBrain) return baseTrace; if (!brainRoot || !fs.existsSync(brainRoot)) { return { ...baseTrace, reason: 'Second Brain was requested, but the active brain folder does not exist.' }; } const files = findBrainFiles(brainRoot); const terms = tokenize(retrievalQuery); const scored = files.map((file) => scoreFile(file, brainRoot, terms)) .filter((doc) => doc.score > 0) .sort((a, b) => b.score - a.score) .slice(0, options.limit || 5); const usedDocs = scored.slice(0, Math.min(3, scored.length)).map((doc) => ({ ...doc, usedInAnswer: true, usedFor: inferUsedFor(doc.excerpt) })); const unusedDocs = scored.slice(usedDocs.length).map((doc) => ({ ...doc, usedInAnswer: false, excludedReason: 'Lower relevance than the documents selected as answer context.' })); const retrievedDocuments = [...usedDocs, ...unusedDocs]; const usedCount = retrievedDocuments.filter((doc) => doc.usedInAnswer).length; return { ...baseTrace, secondBrainUsed: retrievedDocuments.length > 0, reason: retrievedDocuments.length > 0 ? 'Relevant Markdown notes were found and selected as answer context.' : 'Second Brain search ran, but no sufficiently relevant Markdown notes were found.', searchedCollections: inferCollections(retrievedDocuments), retrievedDocuments, groundingScore: retrievedDocuments.length === 0 ? 0 : Number((usedCount / retrievedDocuments.length).toFixed(2)) }; } export function renderSecondBrainTraceContext(trace: SecondBrainTrace): string { if (!trace.shouldUseSecondBrain) { return [ '[SECOND BRAIN TRACE]', 'Second Brain was not used for this request.', `Reason: ${trace.reason}`, 'If the user explicitly asks to use Second Brain or asks project-specific memory questions, use it.' ].join('\n'); } const docs = trace.retrievedDocuments .filter((doc) => doc.usedInAnswer) .map((doc) => [ `- ${doc.path}`, ` Score: ${doc.score}`, ` Relevant content: ${doc.excerpt}` ].join('\n')) .join('\n'); return [ '[SECOND BRAIN TRACE]', `Second Brain used: ${trace.secondBrainUsed ? 'yes' : 'no'}`, `Retrieval query: ${trace.retrievalQuery}`, `Reason: ${trace.reason}`, docs ? `Selected notes:\n${docs}` : 'Selected notes: none', '', 'When answering, use only selected notes that are relevant. If these notes influence the answer, mention them in the final reference section.' ].join('\n'); } export function renderSecondBrainTraceMarkdown(trace: SecondBrainTrace, debug: boolean = false): string { const usedDocs = trace.retrievedDocuments.filter((doc) => doc.usedInAnswer); const unusedDocs = trace.retrievedDocuments.filter((doc) => !doc.usedInAnswer); const usedText = usedDocs.length ? usedDocs.map((doc) => [ `- \`${doc.path}\``, ` - Score: ${doc.score}`, ` - 참고 내용: ${doc.excerpt}` ].join('\n')).join('\n') : '- 없음'; const unusedText = unusedDocs.length ? unusedDocs.map((doc) => [ `- \`${doc.path}\``, ` - 제외 이유: ${doc.excludedReason || '이번 답변의 핵심 근거로 선택되지 않았습니다.'}` ].join('\n')).join('\n') : '- 없음'; const sections = [ '', '## 2nd Brain 사용 여부', trace.secondBrainUsed ? '사용함' : '사용하지 않음', '', '## 이유', trace.reason, '', '## 참고한 2nd Brain 문서', usedText, '', '## 검색했지만 사용하지 않은 문서', unusedText, '', '## 참고 품질', `- 검색된 노트: ${trace.retrievedDocuments.length}개`, `- 실제 사용된 노트: ${usedDocs.length}개`, `- 답변 근거도: ${trace.groundingScore}` ]; if (debug) { sections.push( '', '## Second Brain Debug JSON', '```json', JSON.stringify({ secondBrainUsed: trace.secondBrainUsed, shouldUseSecondBrain: trace.shouldUseSecondBrain, retrievalQuery: trace.retrievalQuery, searchedCollections: trace.searchedCollections, retrievedDocuments: trace.retrievedDocuments.map((doc) => ({ path: doc.path, score: doc.score, usedInAnswer: doc.usedInAnswer, usedFor: doc.usedFor, excludedReason: doc.excludedReason })), groundingScore: trace.groundingScore }, null, 2), '```' ); } return sections.join('\n'); } function shouldUseBrain(query: string): boolean { const normalized = query.toLowerCase(); return /(second brain|2nd brain|제2뇌|브레인|brain|기억|기록|문서|노트|내가|우리|프로젝트|결정|adr|chronicle|가드|설계 원칙|mvp|제외|왜)/i.test(normalized); } function buildRetrievalQuery(query: string): string { return tokenize(query).slice(0, 16).join(' '); } function tokenize(value: string): string[] { const stopWords = new Set(['그리고', '그런데', '해서', '하는', '있어', 'what', 'how', 'the', 'and', 'for', 'with']); return value .toLowerCase() .split(/[^a-z0-9가-힣_]+/g) .map((term) => term.trim()) .filter((term) => term.length >= 2 && !stopWords.has(term)); } function scoreFile(file: string, brainRoot: string, terms: string[]): SecondBrainTraceDocument { const relative = path.relative(brainRoot, file); const title = path.basename(file, path.extname(file)); const basename = relative.toLowerCase(); let content = ''; try { content = fs.readFileSync(file, 'utf8'); } catch { content = ''; } const lower = content.toLowerCase(); let score = 0; for (const term of terms) { if (basename.includes(term)) score += 4; const matches = lower.split(term).length - 1; if (matches > 0) score += Math.min(matches, 6); } return { title, path: relative, absolutePath: file, score: Number((score / Math.max(terms.length, 1)).toFixed(2)), excerpt: summarizeText(bestExcerpt(content, terms), 420), usedInAnswer: false }; } function bestExcerpt(content: string, terms: string[]): string { const paragraphs = content .split(/\n\s*\n/g) .map((part) => part.replace(/\s+/g, ' ').trim()) .filter(Boolean); if (paragraphs.length === 0) return ''; let best = paragraphs[0]; let bestScore = -1; for (const paragraph of paragraphs) { const lower = paragraph.toLowerCase(); const score = terms.reduce((sum, term) => sum + (lower.includes(term) ? 1 : 0), 0); if (score > bestScore) { best = paragraph; bestScore = score; } } return best; } function inferCollections(docs: SecondBrainTraceDocument[]): string[] { const collections = new Set(); for (const doc of docs) { const first = doc.path.split(/[\\/]/)[0]; if (first) collections.add(first); } return Array.from(collections); } function inferUsedFor(excerpt: string): string { if (/의존|coupl|독립|분리/i.test(excerpt)) return '의존도와 독립 모듈 판단'; if (/markdown|마크다운/i.test(excerpt)) return 'Markdown 기반 저장 방향'; if (/질문|의도|reason/i.test(excerpt)) return '질문 의도와 기록 방식'; if (/mvp|제외|scope/i.test(excerpt)) return 'MVP 범위 판단'; return '프로젝트 고유 맥락 확인'; }