feat: Bridge 타깃 토글 + /research 제거 + 환각·오염 방지 강화 (v2.2.205)

- Datacollect Bridge 로컬/NAS 타깃 토글(Settings 패널) + NAS URL/x-bridge-token.
  기본 local = 현행 동작 유지. (백엔드 NAS 분리 준비)
- /research(NotebookLM) 제거 — 로컬 Datacollect 앱 전용으로 분리.
- 에러로그 오염 차단: STT/스택트레이스/에러덤프를 장기기억 채굴 제외 + 자동
  추출 항목 14일 TTL(참조 시 슬라이딩 연장). 기존·수동 항목 무영향.
- 컨텍스트 [주제] 태깅 + 교차오염 방지 경계 지침.
- "확인 불가" 사실 날조 금지 규칙(R7과 구분).
- /meet STT 오타 보정: 철자 정규화 허용하되 사실 날조는 차단.

타입체크 + 407 테스트 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 16:47:55 +09:00
parent 2ea5185cd6
commit 6b017b0d31
14 changed files with 213 additions and 127 deletions
+29 -16
View File
@@ -4,19 +4,36 @@ import * as vscode from 'vscode';
* Datacollect (Wiki/Datacollect 프로젝트)의 MCP Bridge HTTP 클라이언트.
*
* Bridge는 사용자가 별도로 띄우는 Node Express 서버(`npm run bridge`)이고
* 기본 포트는 3002. Research(NotebookLM)/Web Benchmark(Playwright)/YouTube
* (yt-dlp+transcript) 같은 무거운 기능을 노출하므로, Astra는 이 endpoint를
* thin client로 호출만 한다 — Playwright/Chrome/NotebookLM-MCP 의존성을
* Astra가 직접 들고 갈 필요 없음.
* 기본 포트는 3002. Web Benchmark(Playwright)/YouTube(yt-dlp+transcript)/Wikify
* 같은 무거운 기능을 노출하므로, Astra는 이 endpoint를 thin client로 호출만 한다
* — Playwright/Chrome/Python 의존성을 Astra가 직접 들고 갈 필요 없음.
* (NotebookLM Deep Research 는 ASTRA 에서 제거 — 로컬 Datacollect 앱 전용.)
*
* URL은 `astra.datacollectBridgeUrl` VS Code 설정으로 override 가능, 기본값
* `http://127.0.0.1:3002`. 사용자가 다른 머신/포트에서 띄우면 그쪽으로 가게.
* 타깃은 `g1nation.datacollectBridgeTarget`(`local`|`nas`)으로 전환한다.
* - local(기본): `g1nation.datacollectBridgeUrl` (기본 `http://127.0.0.1:3002`)
* - nas: `g1nation.datacollectBridgeNasUrl` (+ `datacollectBridgeNasToken` 헤더)
* nas 인데 URL 이 비어 있으면 안전하게 local 로 폴백한다(절대 깨지지 않게).
*/
export function getBridgeBaseUrl(): string {
const raw = vscode.workspace.getConfiguration('g1nation').get<string>('datacollectBridgeUrl');
const url = (raw && raw.trim()) || 'http://127.0.0.1:3002';
return url.replace(/\/$/, '');
const cfg = vscode.workspace.getConfiguration('g1nation');
const localUrl = (cfg.get<string>('datacollectBridgeUrl')?.trim()) || 'http://127.0.0.1:3002';
if (cfg.get<string>('datacollectBridgeTarget', 'local') === 'nas') {
const nasUrl = cfg.get<string>('datacollectBridgeNasUrl')?.trim();
if (nasUrl) return nasUrl.replace(/\/$/, '');
// nas 선택했으나 URL 미설정 → 로컬로 폴백 (구동 끊기지 않게).
}
return localUrl.replace(/\/$/, '');
}
/**
* nas 타깃일 때 NAS Bridge 의 `x-bridge-token` 값. local 이거나 미설정이면 ''.
* bridgeFetch 가 이 값을 요청 헤더에 실어 보낸다(빈 문자열이면 헤더 미부착).
*/
export function getBridgeAuthToken(): string {
const cfg = vscode.workspace.getConfiguration('g1nation');
if (cfg.get<string>('datacollectBridgeTarget', 'local') !== 'nas') return '';
return (cfg.get<string>('datacollectBridgeNasToken')?.trim()) || '';
}
/**
@@ -26,19 +43,13 @@ export function getBridgeBaseUrl(): string {
* 바뀌면 5+ 곳을 동시 수정. 이 객체로 모아 한 곳만 바꾸면 됨.
*
* 카테고리:
* - research: NotebookLM Deep Research 워크플로
* - youtube: yt-dlp + youtube-transcript-api 기반 영상 분석
* - web: Playwright 기반 웹 페이지 추출·벤치마크
* - wiki: 생성된 위키 문서 디스크 저장
* - lm: 사용자의 LM Studio / Ollama 로 LLM 호출 프록시
*/
export const BRIDGE_API = {
research: {
start: '/api/research/start',
status: '/api/research/status',
import: '/api/research/import',
synthesize: '/api/research/synthesize',
},
// research(NotebookLM)는 ASTRA 에서 제거됨(v2.2.205) — 로컬 Datacollect 앱 전용.
youtube: {
extract: '/api/youtube/extract',
},
@@ -104,11 +115,13 @@ export async function bridgeFetch<T = any>(
}
try {
const token = getBridgeAuthToken();
const res = await fetch(url, {
...init,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(token ? { 'x-bridge-token': token } : {}),
...(init?.headers || {}),
},
});
+5 -96
View File
@@ -1,5 +1,6 @@
/**
* Datacollect handlers — /research · /benchmark · /youtube · /blog · /wikify · /meet.
* Datacollect handlers — /benchmark · /youtube · /blog · /wikify · /meet.
* (/research(NotebookLM)는 v2.2.205 에서 제거 — 로컬 Datacollect 앱 전용으로 분리)
*
* v2.2.201 에서 slashRouter.ts 에서 분리. Datacollect bridge (port 3002) 통합
* 슬래시 명령 클러스터 — NotebookLM Deep Research · Playwright 웹 스캔 ·
@@ -33,100 +34,6 @@ import {
parseActionItems,
} from './scheduling/calendarHelpers';
// ───────────────────────────── /research ─────────────────────────────
async function runResearch(topic: string, view: Webview | undefined): Promise<boolean> {
if (!topic) {
chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`);
return true;
}
chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`);
const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>(
BRIDGE_API.research.start,
{ method: 'POST', body: JSON.stringify({ topic }) },
{ timeoutMs: 60_000 },
);
chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`);
const deadline = Date.now() + 10 * 60_000;
const HEARTBEAT_MS = 30_000;
const MAX_CONSECUTIVE_FAILS = 5;
const COMPLETED_SET = new Set(['completed', 'done', 'success', 'finished']);
const FAILED_SET = new Set(['failed', 'error', 'cancelled', 'canceled', 'aborted']);
let lastStatus = '';
let lastChangeAt = Date.now();
let consecutiveFails = 0;
let pollCount = 0;
let researchOk = false;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 5_000));
pollCount++;
let st: { success: boolean; result: any } | undefined;
try {
st = await bridgeFetch<{ success: boolean; result: any }>(
`${BRIDGE_API.research.status}?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`,
{ method: 'GET' },
{ timeoutMs: 60_000 },
);
consecutiveFails = 0;
} catch (e: any) {
consecutiveFails++;
if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
chunk(view, `\n❌ Status polling 연속 실패 ${consecutiveFails}회 — bridge 가 응답하지 않습니다. 중단합니다.\n(원인: ${e?.message || String(e)})\n`);
return true;
}
chunk(view, `\n · status 호출 실패 ${consecutiveFails}/${MAX_CONSECUTIVE_FAILS} (${e?.message || 'unknown'})\n`);
continue;
}
const status = String(st.result?.status || st.result || '').trim().toLowerCase();
if (status && status !== lastStatus) {
chunk(view, ` · ${status}\n`);
lastStatus = status;
lastChangeAt = Date.now();
} else if (Date.now() - lastChangeAt > HEARTBEAT_MS) {
chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`);
lastChangeAt = Date.now();
}
if (COMPLETED_SET.has(status)) { researchOk = true; break; }
if (FAILED_SET.has(status)) {
chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`);
return true;
}
}
if (!researchOk) {
chunk(view, `\n❌ 10분 polling 후에도 완료 신호가 오지 않았습니다 (마지막 status: \`${lastStatus || '(없음)'}\`). 중단합니다.\n`);
return true;
}
chunk(view, `\n📥 import…\n`);
await bridgeFetch(BRIDGE_API.research.import, {
method: 'POST',
body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }),
}, {
timeoutMs: 300_000,
onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`),
});
chunk(view, `🧪 synthesize…\n\n`);
const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>(
BRIDGE_API.research.synthesize,
{
method: 'POST',
body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }),
},
{
timeoutMs: 600_000,
onHeartbeat: (elapsedMs) => chunk(view, ` · synthesize LLM 작업 중 (${Math.round(elapsedMs / 1000)}s)\n`),
},
);
const md = synth.markdown || synth.result || '(빈 응답)';
chunk(view, `---\n\n${md}\n`);
return true;
}
// ───────────────────────────── /benchmark ─────────────────────────────
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
@@ -749,7 +656,9 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
// ─── 등록 ─────────────────────────────────────────────────────────────────
registerSlashCommand({ name: '/research', description: 'NotebookLM Deep Research 호출 후 위키 합성', handler: runResearch });
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
+13 -4
View File
@@ -6,8 +6,9 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
const metaBlock = metadata.trim()
|| '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)';
return `# 임무 (Objective)
제공된 회의 녹취 텍스트와 메타데이터를 기반으로, 외부 지식 없이 사실 기반의
구조화된 회의록(Actionable Minutes)을 생성한다.
제공된 회의 녹취 텍스트와 메타데이터를 기반으로, 사실 기반의 구조화된
회의록(Actionable Minutes)을 생성한다. 외부/도메인 지식은 *STT 오타 보정과 용어
해석*에만 사용하고, *녹취록에 없는 새로운 사실을 추가*하는 데는 절대 쓰지 않는다.
# 역할 (Role)
- Fact Extractor: 녹취록에 명시적으로 존재하는 사실만 추출
@@ -19,6 +20,14 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
# 데이터 우선순위 (Data Priority)
1순위: 메타데이터 / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다.
# STT 오타 보정 (Transcription Noise Handling — 이 녹취록은 음성→텍스트 변환물이라 오타가 많다)
- 발음이 유사한 단어가 잘못 표기돼 있다(예: "Dovrunner"→"Doverunner", "페어플레이"→"페어플래이"). **한 단어의 철자에 집착하지 말고 주변 문맥(앞뒤 키워드)으로 의미를 복원하라.**
- 발음이 유사한 명백한 오타는 문맥상 맞는 기술 용어·고유명사로 **정규화**하라. 흔한 기술 용어(DRM, SDK, 딥링크, API, 렌더링, 페어플레이, 암호화 등)는 도메인 지식으로 보정해도 된다.
- 메타데이터에 인명·기업명·제품명·용어가 주어졌으면 그것을 **정답 표기**로 보고, 녹취록의 유사 오타를 그 표기로 맞춘다(메타데이터는 사실상 용어집 역할).
- **핵심 구분 (절대 혼동 금지)**: '녹취록에 *있는* 단어의 철자를 문맥으로 바로잡는 것'은 허용·권장된다. '녹취록에 *없는* 사실(수치·결정·없던 항목)을 지어내는 것'은 금지다. **철자 보정 ≠ 사실 날조.**
- 철자가 틀려도 문맥상 의미가 분명하면 그 의미를 확정된 것으로 다뤄라 — **오타 하나 때문에 멀쩡한 내용 전체를 "확인 불가"로 막지 말 것.**
- 정규화는 했지만 문맥으로도 정체가 끝내 모호한 용어에 한해, 정규화 표기 옆에 원문을 함께 남긴다: 예) \`Doverunner(원문: "Dovrunner", 표기 불확실)\`.
# 처리 절차 (Processing Flow)
1. Speaker Tracking — 발언자 ID/이름을 끝까지 유지한다. **누가 한 말인지를 절대 임의로 바꾸거나 합치지 말 것.**
2. Topic Reclustering — 이 녹취록은 비선형이다(A→B→Z→다시 A 식으로 주제가 튄다). 녹취록 전체를 훑어 **흩어진 발언을 주제별로 다시 묶은 뒤** 정리한다. 녹취록상 앞뒤로 붙어 있다는 이유만으로 두 발언을 인과·연결 관계로 엮지 말 것(**인접 ≠ 연결**).
@@ -34,8 +43,8 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
# 근거·정확성 규칙 (Grounding Rules — 반드시 준수, 할루시네이션 방지)
- **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개의 발언을 하나의 인과 사슬로 합성하지 말 것.
- **발언 주체가 불명확하면 추측하지 말 것.** 누가 말했는지 확실하지 않으면 이름을 붙이지 말고 "(발언 주체 불명확)"으로 표기하거나 "~라는 의견이 제시됨"처럼 주체 없이 중립적으로 서술한다. 어떤 발언자의 말을 다른 발언자의 결론으로 옮기는 것은 가장 심각한 오류다.
- **녹취록에 없는 숫자·날짜·금액·고유명사·제품명을 만들어내지 말 것.** 불확실하면 "확인 필요"로 둔다.
- 어떤 항목의 근거가 녹취록에서 약하거나 모호하면, 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다.
- **녹취록에 없는 숫자·날짜·금액·결정·없던 항목을 만들어내지 말 것.** (단, 녹취록에 *있는데 철자만 틀린* 용어·고유명사를 문맥으로 정규화하는 것은 허용 — 위 'STT 오타 보정' 참조.) 정말 근거 없는 *사실*만 "확인 필요"로 둔다.
- 어떤 항목의 *내용 자체*가 녹취록에서 약하거나 모호하면(=철자 문제가 아니라 사실이 불확실) 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다. 단순 표기 오타는 여기 해당하지 않는다.
- Decision은 명시적 합의 표현이 있을 때만 '결정됨'이다. 합의가 불명확하면 '논의 중' 또는 오픈 이슈로 둔다.
# 출력 검증 (Validation)
@@ -88,7 +88,13 @@ interface SettingsState {
polishPersonaOverride: string;
};
datacollect: {
/** 'local' | 'nas' — 어느 Bridge 인스턴스를 호출할지. */
bridgeTarget: string;
bridgeUrl: string;
/** NAS 경량 Bridge URL (nas 타깃일 때). */
bridgeNasUrl: string;
/** NAS Bridge 의 x-bridge-token (nas 타깃일 때 헤더로 전송). */
bridgeNasToken: string;
/** Empty → results saved to the Bridge's WIKI_RAW_PATH default. */
savePath: string;
crawlDepth: number;
@@ -605,9 +611,19 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
// savePath 가 비어 있으면 Bridge 의 WIKI_RAW_PATH 환경변수가 저장 위치를 결정한다.
private async _handleDatacollectUpdate(msg: any): Promise<void> {
if (typeof msg.bridgeTarget === 'string') {
const t = msg.bridgeTarget.trim() === 'nas' ? 'nas' : 'local';
await this._safeConfigUpdate('datacollectBridgeTarget', t);
}
if (typeof msg.bridgeUrl === 'string') {
await this._safeConfigUpdate('datacollectBridgeUrl', msg.bridgeUrl.trim());
}
if (typeof msg.bridgeNasUrl === 'string') {
await this._safeConfigUpdate('datacollectBridgeNasUrl', msg.bridgeNasUrl.trim());
}
if (typeof msg.bridgeNasToken === 'string') {
await this._safeConfigUpdate('datacollectBridgeNasToken', msg.bridgeNasToken.trim());
}
if (typeof msg.savePath === 'string') {
await this._safeConfigUpdate('datacollectSavePath', msg.savePath.trim());
}
@@ -675,7 +691,10 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
polishPersonaOverride: cfg.get<string>('polishPersonaOverride', '') ?? '',
},
datacollect: {
bridgeTarget: cfg.get<string>('datacollectBridgeTarget', 'local') || 'local',
bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '',
bridgeNasUrl: cfg.get<string>('datacollectBridgeNasUrl', '') || '',
bridgeNasToken: cfg.get<string>('datacollectBridgeNasToken', '') || '',
savePath: cfg.get<string>('datacollectSavePath', '') || '',
crawlDepth: cfg.get<number>('datacollectCrawlDepth', 1) ?? 1,
maxPages: cfg.get<number>('datacollectMaxPages', 8) ?? 8,
+1 -1
View File
@@ -165,7 +165,7 @@ export async function runDatacollectSetup(): Promise<void> {
if (after.missingPackages.length === 0) {
output.appendLine('\n✅ 설치 후 import 검증 통과. Datacollect 슬래시 명령을 바로 쓸 수 있습니다.');
vscode.window.showInformationMessage(
`Astra Setup 완료: ${probe.missingPackages.join(', ')} 설치됨. /youtube /research 등 다시 시도해 보세요.`,
`Astra Setup 완료: ${probe.missingPackages.join(', ')} 설치됨. /youtube 등 다시 시도해 보세요.`,
);
} else {
output.appendLine(`\n⚠️ pip 은 성공으로 끝났지만 import 검증에서 여전히 ${after.missingPackages.join(', ')} 가 안 보입니다.`);
+2 -2
View File
@@ -311,8 +311,8 @@ const HELP_CATEGORIES: HelpCategory[] = [
{
title: '리서치·분석',
emoji: '🔬',
match: (n) => ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
blurb: 'Datacollect bridge 통합 — Deep Research / 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록',
match: (n) => ['/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
blurb: 'Datacollect bridge 통합 — 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록 (NotebookLM Deep Research 는 로컬 Datacollect 앱으로 분리)',
},
{
title: '시스템·메모리',
+43 -2
View File
@@ -169,6 +169,12 @@ export class LongTermMemory {
.slice(0, 5);
if (alwaysInclude.length === 0) return null;
// 표시되는(=사용되는) 자동 추출 항목의 만료를 연장.
const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
for (const e of alwaysInclude) {
if (e.expiresAt) { e.expiresAt = refreshAt; this.dirty = true; }
}
const content = alwaysInclude
.map((e) => `- [${e.category}] ${e.content}`)
.join('\n');
@@ -181,10 +187,13 @@ export class LongTermMemory {
};
}
// Mark as referenced
// Mark as referenced — 자동 추출(만료 있음) 항목은 참조 시 만료를 슬라이딩 연장해
// '쓰면 살아남고, 안 쓰면 TTL 뒤 소멸'. 영속(수동) 항목은 expiresAt 이 없어 무영향.
const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
for (const { entry } of relevant) {
entry.lastReferencedAt = Date.now();
entry.referenceCount++;
if (entry.expiresAt) entry.expiresAt = refreshAt;
}
this.dirty = true;
@@ -202,6 +211,34 @@ export class LongTermMemory {
// ─── Extraction Helpers ───
/** 자동 추출 장기기억 기본 TTL (14일). 참조될 때마다 슬라이딩 연장된다. */
public static readonly AUTO_EXTRACT_TTL_MS = 14 * 24 * 60 * 60 * 1000;
/** 짧은 후보 문자열에 박힌 구체적 에러 시그니처(예외명/에러코드/스택 조각) 탐지. */
private static readonly ERROR_NOISE = /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError|Exception|Traceback|ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET|errno|npm ERR!)\b|error\s+TS\d|:\d+:\d+\)|File ".+", line \d/i;
/**
* 붙여넣은 에러 로그·스택 트레이스·실패 출력처럼 보이는 텍스트인지 *보수적으로* 추정.
* 이런 입력은 '분석 대상'(휘발)이지 '지식'(영속)이 아니므로 장기 기억 채굴에서 제외한다.
* 일반 산문이 'error' 를 한 번 언급한 정도로는 걸리지 않게 강한/약한 신호를 구분한다.
*/
public static looksLikeErrorLog(text: string): boolean {
if (!text) return false;
const strong = [
/Traceback \(most recent call last\)/,
/^\s*at\s+.+\(.+:\d+:\d+\)/m, // JS 스택 프레임
/\bFile ".+", line \d+/, // Python 프레임
/npm ERR!/,
/\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError)\b/,
/\b(?:ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET)\b/,
/error\s+TS\d{3,}/i, // tsc 에러
];
if (strong.some((re) => re.test(text))) return true;
const weak = (text.match(/\b(?:error|errors|exception|failed|failure|stacktrace|errno|fatal|panic|assertion)\b/gi) || []).length
+ (text.match(/\[(?:error|warn|fatal)\]/gi) || []).length;
return weak >= 3 && text.split('\n').length >= 3;
}
/**
* 대화 메시지에서 장기 기억 후보를 패턴 매칭으로 추출합니다.
* LLM 호출 없이 동작합니다.
@@ -235,6 +272,8 @@ export class LongTermMemory {
for (const msg of messages) {
if (msg.role !== 'user') continue;
const text = msg.content;
// 에러 로그/스택 트레이스 덤프는 '분석 대상'(휘발)이므로 통째로 채굴 제외.
if (LongTermMemory.looksLikeErrorLog(text)) continue;
for (const pattern of rulePatterns) {
pattern.lastIndex = 0;
@@ -269,9 +308,11 @@ export class LongTermMemory {
}
}
// Deduplicate by content
// Deduplicate by content + 에러 시그니처가 박힌 후보 제거
// ('goal: fix ECONNREFUSED ...' 같은 에러 내용이 지식으로 흡수되는 오염 방지).
const seen = new Set<string>();
return candidates.filter((c) => {
if (LongTermMemory.ERROR_NOISE.test(c.content)) return false;
const key = c.content.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
+6 -1
View File
@@ -38,13 +38,18 @@ export class MemoryExtractor {
};
// 1. Long-Term Memory 추출
// 자동 추출 항목엔 TTL(14일)을 부여 — 참조될 때마다 슬라이딩 연장되므로 실제로
// 쓰이는 지식은 살아남고, 한 번 들어온 일회성·잡음 내용은 14일 뒤 자연 소멸한다.
// (에러 로그/실패 데이터는 extractCandidates 단계에서 이미 걸러짐.)
const candidates = LongTermMemory.extractCandidates(messages);
const expiresAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
for (const candidate of candidates) {
longTermMemory.addEntry(
candidate.category,
candidate.content,
`session:${sessionId}`,
0.7 // 자동 추출이므로 기본 신뢰도 0.7
0.7, // 자동 추출이므로 기본 신뢰도 0.7
{ expiresAt },
);
}
result.longTermCandidates = candidates.length;
+17 -1
View File
@@ -90,6 +90,19 @@ export function selectWithinBudget(
return { selected, dropped, tokensUsed };
}
/**
* 청크의 '주제(Subject)' 태그를 도출한다 — 서로 다른 프로젝트/주제의 정보가 한
* 컨텍스트에 섞일 때 모델이 경계를 인지하도록(무성 교차오염 방지). category 가 있으면
* 그걸, 없으면 title/filePath 의 최상위 폴더 세그먼트를 주제로 본다. 파일명만 있으면 ''.
*/
function deriveSubject(chunk: RetrievalChunk): string {
const cat = (chunk.metadata.category || '').trim();
if (cat) return cat;
const ref = (chunk.title || chunk.metadata.filePath || '').replace(/\\/g, '/');
const seg = ref.split('/').filter(Boolean);
return seg.length >= 2 ? seg[0] : '';
}
/**
* 선택된 청크들을 하나의 컨텍스트 문자열로 조립합니다.
* 소스별로 그룹화하여 가독성을 높입니다.
@@ -123,9 +136,11 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
const items = groupChunks
.map((c) => {
const metadata = c.metadata;
const subject = deriveSubject(c);
const subjectTag = subject ? `[${subject}] ` : '';
const conflictTag = metadata.conflictDetected ? ` [⚠️ CONFLICT: ${metadata.conflictSeverity}]` : '';
const coverageTag = metadata.queryCoverage !== undefined ? ` (Coverage: ${metadata.queryCoverage.toFixed(2)})` : '';
return `- ${c.title}${conflictTag}${coverageTag}: ${c.content}`;
return `- ${subjectTag}${c.title}${conflictTag}${coverageTag}: ${c.content}`;
})
.join('\n');
sections.push(`### ${label}\n${items}`);
@@ -134,6 +149,7 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
return [
'[MEMORY CONTEXT]',
'Review this layered memory before preparing the answer. Use it only when relevant, and prefer the current user request when there is conflict.',
'각 항목 앞의 [주제] 태그와 섹션 출처를 확인하라. **현재 요청과 다른 프로젝트·주제의 항목은 사용하지 마라** — 서로 다른 프로젝트의 규칙·결정·수치·고유명사를 섞지 말 것. 어느 항목이 현재 작업과 관련 있는지 불확실하면 그 항목에 의존하지 마라.',
'',
sections.join('\n\n')
].join('\n');
+1
View File
@@ -234,6 +234,7 @@ Then reply with one short line stating what was started and where.
2. [NO MARKDOWN MARKERS] PLAIN TEXT ONLY. Do NOT emit "#", "##", "###", "**", "__", "> ", "* " as formatting. Section labels are bare Korean words on their own line (e.g. a line that says just "핵심 요약" — no "#", no "**"). Bullets use "- " only. Inline code with backticks (e.g. \`src/agent.ts\`) and triple-backtick code blocks for actual code are fine.
3. [NO INTERNAL LOGS] Never output <details>, "2nd Brain Trace", or "Debug JSON" blocks.
4. [NO SECTION LEAKAGE] Never output sections named "요청 요약", "사용자 의도 추론", "프로젝트 기록 대상 확인", "핵심 확인 질문", or "근거 파일 경로".
5. [확인 불가 — 사실 날조 금지] 지식 베이스·제공된 컨텍스트·이번 세션에 읽은 파일에 근거가 없는 사실(수치, 날짜, 금액, 고유명사, 파일/함수/포트명, 결정 사항, "이미 ~다/~로 정해졌다" 류 단정)은 지어내지 마라. 근거가 없으면 추측으로 메우지 말고 "확인 불가" 또는 "근거 없음 — 확인 필요"라고 명시하라. 불확실하면 단정 톤을 낮춰라("~로 보인다", "확인 필요"). 단, 이 규칙은 *사실 주장*에만 적용된다 — R7 의 '합리적 가정 후 진행'은 *작업 수행*의 기본값 선택에는 그대로 유효하다(가정은 "가정:" 한 줄로 밝힌다).
[OUTPUT FORMAT — 7 hard rules]
These rules override any other formatting habit. Apply them to EVERY answer.