From 6b017b0d31bf693181c6193a2acef40fdccd1600 Mon Sep 17 00:00:00 2001 From: g1nation Date: Fri, 5 Jun 2026 16:47:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Bridge=20=ED=83=80=EA=B9=83=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20+=20/research=20=EC=A0=9C=EA=B1=B0=20+=20=ED=99=98?= =?UTF-8?q?=EA=B0=81=C2=B7=EC=98=A4=EC=97=BC=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EA=B0=95=ED=99=94=20(v2.2.205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PATCHNOTES.md | 13 +++ media/settings-panel.html | 31 +++++- media/settings-panel.js | 17 +++ package.json | 20 +++- src/features/datacollect/bridgeClient.ts | 45 +++++--- src/features/datacollect/handlers.ts | 101 +----------------- .../datacollect/prompts/meetPrompt.ts | 17 ++- .../settings/settingsPanelProvider.ts | 19 ++++ src/features/setup/datacollectSetup.ts | 2 +- src/features/system/handlers.ts | 4 +- src/memory/LongTermMemory.ts | 45 +++++++- src/memory/MemoryExtractor.ts | 7 +- src/retrieval/contextBudget.ts | 18 +++- src/utils.ts | 1 + 14 files changed, 213 insertions(+), 127 deletions(-) diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 8299d89..4d4e804 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,18 @@ # Astra Patch Notes +## v2.2.205 (2026-06-05) +### 🧹 백엔드 분리 준비 — Bridge 타깃 토글(로컬/NAS) + /research 제거 +- **Datacollect Bridge 타깃 설정** 추가 — Astra Settings 패널에서 `로컬/NAS` 전환 + NAS URL/토큰(`x-bridge-token`). 기본 `로컬` = 현행 동작 그대로. ([bridgeClient.ts](src/features/datacollect/bridgeClient.ts) · [settings-panel](media/settings-panel.html) · [settingsPanelProvider.ts](src/features/settings/settingsPanelProvider.ts)) +- **`/research`(NotebookLM) 제거** — Chrome/Google 로그인 의존이라 로컬 Datacollect 앱 전용으로 분리. benchmark/youtube/wikify/blog/meet 는 유지. ([handlers.ts](src/features/datacollect/handlers.ts)) + +### 🛡️ 환각·오염 방지 강화 (코드 검토 기반) +- **에러로그 오염 차단** — STT/스택트레이스/에러덤프를 장기기억 채굴에서 제외(`looksLikeErrorLog`, `ERROR_NOISE`) + 자동 추출 항목에 14일 TTL(참조 시 슬라이딩 연장). 기존·수동 항목 무영향. ([LongTermMemory.ts](src/memory/LongTermMemory.ts) · [MemoryExtractor.ts](src/memory/MemoryExtractor.ts)) +- **컨텍스트 주제 태깅** — 검색 청크에 `[주제]` 태그 + "다른 프로젝트·주제 섞지 말라" 경계 지침으로 무성 교차오염 방지. ([contextBudget.ts](src/retrieval/contextBudget.ts)) +- **"확인 불가" 블랭킷 규칙** — 근거 없는 사실 날조 금지(수치/날짜/고유명사/결정), R7(가정 후 진행)과 구분. ([utils.ts](src/utils.ts)) + +### 🎙️ /meet STT 오타 보정 +- 음성→텍스트 오타를 문맥·도메인 지식으로 정규화하되 **"철자 보정 ≠ 사실 날조"** 명시 — 오타 하나로 전체를 "확인 불가"로 막지 않게. metadata 를 즉석 용어집으로 활용. ([meetPrompt.ts](src/features/datacollect/prompts/meetPrompt.ts)) + ## v2.2.204 (2026-06-04) ### ✨ `/weekly` 전면 교체 — 캘린더 task 기반 주간 보고서 (금주/차주) - **기존 `/weekly`(대표용 CEO 주간 리뷰 카드 — 고객/채용/런웨이 집계)는 제거**하고, `/weekly` 를 task 기반 금주/차주 보고서로 일원화. (제거: [dashboards.ts](src/features/teamops/handlers/dashboards.ts) `runWeekly` + weekly 전용 헬퍼) diff --git a/media/settings-panel.html b/media/settings-panel.html index aa0dde9..0bf76e0 100644 --- a/media/settings-panel.html +++ b/media/settings-panel.html @@ -48,14 +48,41 @@

Datacollect (slash 명령)

-

채팅에서 /research · /benchmark · /youtube 를 입력하면 Datacollect Bridge로 위임됩니다. Bridge는 Datacollect 프로젝트에서 npm run bridge 로 실행해야 합니다.

+

채팅에서 /research · /benchmark · /youtube 를 입력하면 Datacollect Bridge로 위임됩니다. 타깃으로 로컬(npm run bridge) 또는 NAS의 경량 Bridge 중 어디를 호출할지 선택합니다.

- + +
+ + +
+ local = 아래 로컬 Bridge URL 사용. nas = NAS Bridge URL(+토큰) 사용. nas인데 URL이 비어 있으면 안전하게 로컬로 폴백합니다. +
+
+
+
+ +
+ + +
+ 타깃이 nas일 때 호출할 NAS 경량 Bridge 주소. +
+
+ +
+ + +
+ NAS Bridge의 x-bridge-token. nas 타깃일 때만 요청 헤더에 실립니다. +
diff --git a/media/settings-panel.js b/media/settings-panel.js index 7f8afe3..5196c03 100644 --- a/media/settings-panel.js +++ b/media/settings-panel.js @@ -25,7 +25,10 @@ const cnModelHint = $('cnModelHint'); // ---- Datacollect ---- + const dcBridgeTarget = $('dcBridgeTarget'); const dcBridgeUrl = $('dcBridgeUrl'); + const dcBridgeNasUrl = $('dcBridgeNasUrl'); + const dcBridgeNasToken = $('dcBridgeNasToken'); const dcSavePath = $('dcSavePath'); const dcCrawlDepth = $('dcCrawlDepth'); const dcMaxPages = $('dcMaxPages'); @@ -125,9 +128,18 @@ ); // ---- Datacollect listeners ---- + document.querySelector('[data-save="datacollect.bridgeTarget"]').addEventListener('click', () => + vscode.postMessage({ type: 'datacollect.update', bridgeTarget: dcBridgeTarget.value }) + ); document.querySelector('[data-save="datacollect.bridgeUrl"]').addEventListener('click', () => vscode.postMessage({ type: 'datacollect.update', bridgeUrl: dcBridgeUrl.value }) ); + document.querySelector('[data-save="datacollect.bridgeNasUrl"]').addEventListener('click', () => + vscode.postMessage({ type: 'datacollect.update', bridgeNasUrl: dcBridgeNasUrl.value }) + ); + document.querySelector('[data-save="datacollect.bridgeNasToken"]').addEventListener('click', () => + vscode.postMessage({ type: 'datacollect.update', bridgeNasToken: dcBridgeNasToken.value }) + ); document.querySelector('[data-save="datacollect.savePath"]').addEventListener('click', () => vscode.postMessage({ type: 'datacollect.update', savePath: dcSavePath.value }) ); @@ -385,7 +397,12 @@ // ---- Datacollect ---- const dc = state.datacollect; if (dc) { + if (dcBridgeTarget && document.activeElement !== dcBridgeTarget && (dc.bridgeTarget === 'local' || dc.bridgeTarget === 'nas')) { + dcBridgeTarget.value = dc.bridgeTarget; + } setIfNotFocused(dcBridgeUrl, dc.bridgeUrl); + setIfNotFocused(dcBridgeNasUrl, dc.bridgeNasUrl); + setIfNotFocused(dcBridgeNasToken, dc.bridgeNasToken); setIfNotFocused(dcSavePath, dc.savePath); setIfNotFocused(dcCrawlDepth, dc.crawlDepth); setIfNotFocused(dcMaxPages, dc.maxPages); diff --git a/package.json b/package.json index f3b6691..c07c028 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.204", + "version": "2.2.205", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -204,10 +204,26 @@ "default": false, "description": "Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks." }, + "g1nation.datacollectBridgeTarget": { + "type": "string", + "enum": ["local", "nas"], + "default": "local", + "markdownDescription": "Datacollect 백엔드(Bridge)를 어디로 보낼지 선택. **`local`**(기본) = `g1nation.datacollectBridgeUrl`(로컬 `npm run bridge`). **`nas`** = `g1nation.datacollectBridgeNasUrl`(NAS의 경량 Bridge). `nas`인데 URL이 비어 있으면 안전하게 로컬로 폴백합니다." + }, "g1nation.datacollectBridgeUrl": { "type": "string", "default": "http://127.0.0.1:3002", - "description": "Wiki/Datacollect MCP Bridge URL. /research, /benchmark, /youtube chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project)." + "description": "[local 타깃] Wiki/Datacollect MCP Bridge URL. /benchmark, /youtube, /wikify chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project)." + }, + "g1nation.datacollectBridgeNasUrl": { + "type": "string", + "default": "", + "markdownDescription": "[nas 타깃] NAS에서 도는 경량 Bridge URL (예: `https://your-nas-domain` 또는 `http://nas-ip:3002`). `datacollectBridgeTarget`을 `nas`로 두면 여기로 호출합니다. 비워두면 로컬로 폴백." + }, + "g1nation.datacollectBridgeNasToken": { + "type": "string", + "default": "", + "markdownDescription": "[nas 타깃] NAS Bridge가 요구하는 `x-bridge-token` 값(Bridge의 `BRIDGE_AUTH_TOKEN`과 일치). `nas` 타깃일 때만 요청 헤더에 실립니다. 로컬 타깃에는 영향 없음." }, "g1nation.datacollectSavePath": { "type": "string", diff --git a/src/features/datacollect/bridgeClient.ts b/src/features/datacollect/bridgeClient.ts index e891d5a..f268706 100644 --- a/src/features/datacollect/bridgeClient.ts +++ b/src/features/datacollect/bridgeClient.ts @@ -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('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('datacollectBridgeUrl')?.trim()) || 'http://127.0.0.1:3002'; + if (cfg.get('datacollectBridgeTarget', 'local') === 'nas') { + const nasUrl = cfg.get('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('datacollectBridgeTarget', 'local') !== 'nas') return ''; + return (cfg.get('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( } 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 || {}), }, }); diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index 60ad61c..cf41592 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -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 { - 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 { @@ -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 }); diff --git a/src/features/datacollect/prompts/meetPrompt.ts b/src/features/datacollect/prompts/meetPrompt.ts index 0bc280f..3465f88 100644 --- a/src/features/datacollect/prompts/meetPrompt.ts +++ b/src/features/datacollect/prompts/meetPrompt.ts @@ -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) diff --git a/src/features/settings/settingsPanelProvider.ts b/src/features/settings/settingsPanelProvider.ts index 7fb80a9..8d402d4 100644 --- a/src/features/settings/settingsPanelProvider.ts +++ b/src/features/settings/settingsPanelProvider.ts @@ -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 { + 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('polishPersonaOverride', '') ?? '', }, datacollect: { + bridgeTarget: cfg.get('datacollectBridgeTarget', 'local') || 'local', bridgeUrl: cfg.get('datacollectBridgeUrl', '') || '', + bridgeNasUrl: cfg.get('datacollectBridgeNasUrl', '') || '', + bridgeNasToken: cfg.get('datacollectBridgeNasToken', '') || '', savePath: cfg.get('datacollectSavePath', '') || '', crawlDepth: cfg.get('datacollectCrawlDepth', 1) ?? 1, maxPages: cfg.get('datacollectMaxPages', 8) ?? 8, diff --git a/src/features/setup/datacollectSetup.ts b/src/features/setup/datacollectSetup.ts index ee08b87..06f28eb 100644 --- a/src/features/setup/datacollectSetup.ts +++ b/src/features/setup/datacollectSetup.ts @@ -165,7 +165,7 @@ export async function runDatacollectSetup(): Promise { 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(', ')} 가 안 보입니다.`); diff --git a/src/features/system/handlers.ts b/src/features/system/handlers.ts index b99a217..1f14299 100644 --- a/src/features/system/handlers.ts +++ b/src/features/system/handlers.ts @@ -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: '시스템·메모리', diff --git a/src/memory/LongTermMemory.ts b/src/memory/LongTermMemory.ts index 77e8f21..083a77b 100644 --- a/src/memory/LongTermMemory.ts +++ b/src/memory/LongTermMemory.ts @@ -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(); 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); diff --git a/src/memory/MemoryExtractor.ts b/src/memory/MemoryExtractor.ts index f0e7fa6..a2d14a0 100644 --- a/src/memory/MemoryExtractor.ts +++ b/src/memory/MemoryExtractor.ts @@ -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; diff --git a/src/retrieval/contextBudget.ts b/src/retrieval/contextBudget.ts index 26ce0a7..bf14bd9 100644 --- a/src/retrieval/contextBudget.ts +++ b/src/retrieval/contextBudget.ts @@ -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'); diff --git a/src/utils.ts b/src/utils.ts index 88f20ce..3c0572e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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
, "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.