Files
connectai/src/features/secondBrainTrace.ts
T

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 '프로젝트 고유 맥락 확인';
}