Bump version to 2.35.0: Knowledge Resilience & Standardization Milestone.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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 '프로젝트 고유 맥락 확인';
|
||||
}
|
||||
Reference in New Issue
Block a user