265 lines
9.2 KiB
TypeScript
265 lines
9.2 KiB
TypeScript
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<string>();
|
|
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 '프로젝트 고유 맥락 확인';
|
|
}
|