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
+13
View File
@@ -1,5 +1,18 @@
# Astra Patch Notes # 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) ## v2.2.204 (2026-06-04)
### ✨ `/weekly` 전면 교체 — 캘린더 task 기반 주간 보고서 (금주/차주) ### ✨ `/weekly` 전면 교체 — 캘린더 task 기반 주간 보고서 (금주/차주)
- **기존 `/weekly`(대표용 CEO 주간 리뷰 카드 — 고객/채용/런웨이 집계)는 제거**하고, `/weekly` 를 task 기반 금주/차주 보고서로 일원화. (제거: [dashboards.ts](src/features/teamops/handlers/dashboards.ts) `runWeekly` + weekly 전용 헬퍼) - **기존 `/weekly`(대표용 CEO 주간 리뷰 카드 — 고객/채용/런웨이 집계)는 제거**하고, `/weekly` 를 task 기반 금주/차주 보고서로 일원화. (제거: [dashboards.ts](src/features/teamops/handlers/dashboards.ts) `runWeekly` + weekly 전용 헬퍼)
+29 -2
View File
@@ -48,14 +48,41 @@
<!-- Datacollect --> <!-- Datacollect -->
<section class="section" data-section="datacollect"> <section class="section" data-section="datacollect">
<h2>Datacollect (slash 명령)</h2> <h2>Datacollect (slash 명령)</h2>
<p class="hint">채팅에서 <code>/research</code> · <code>/benchmark</code> · <code>/youtube</code> 를 입력하면 Datacollect Bridge로 위임됩니다. Bridge는 Datacollect 프로젝트에서 <code>npm run bridge</code> 로 실행해야 합니다.</p> <p class="hint">채팅에서 <code>/research</code> · <code>/benchmark</code> · <code>/youtube</code> 를 입력하면 Datacollect Bridge로 위임됩니다. <strong>타깃</strong>으로 로컬(<code>npm run bridge</code>) 또는 NAS의 경량 Bridge 중 어디를 호출할지 선택합니다.</p>
<div class="row"> <div class="row">
<label for="dcBridgeUrl">Bridge URL</label> <label for="dcBridgeTarget">Bridge 타깃</label>
<div class="input-group narrow">
<select id="dcBridgeTarget">
<option value="local">로컬 (Local)</option>
<option value="nas">NAS</option>
</select>
<button data-save="datacollect.bridgeTarget">저장</button>
</div>
<small class="hint"><strong>local</strong> = 아래 로컬 Bridge URL 사용. <strong>nas</strong> = NAS Bridge URL(+토큰) 사용. nas인데 URL이 비어 있으면 안전하게 로컬로 폴백합니다.</small>
</div>
<div class="row">
<label for="dcBridgeUrl">로컬 Bridge URL</label>
<div class="input-group"> <div class="input-group">
<input id="dcBridgeUrl" type="text" placeholder="http://127.0.0.1:3002" spellcheck="false" /> <input id="dcBridgeUrl" type="text" placeholder="http://127.0.0.1:3002" spellcheck="false" />
<button data-save="datacollect.bridgeUrl">저장</button> <button data-save="datacollect.bridgeUrl">저장</button>
</div> </div>
</div> </div>
<div class="row">
<label for="dcBridgeNasUrl">NAS Bridge URL</label>
<div class="input-group">
<input id="dcBridgeNasUrl" type="text" placeholder="https://your-nas-domain 또는 http://nas-ip:3002" spellcheck="false" />
<button data-save="datacollect.bridgeNasUrl">저장</button>
</div>
<small class="hint">타깃이 <strong>nas</strong>일 때 호출할 NAS 경량 Bridge 주소.</small>
</div>
<div class="row">
<label for="dcBridgeNasToken">NAS Bridge 토큰</label>
<div class="input-group">
<input id="dcBridgeNasToken" type="text" placeholder="(NAS의 BRIDGE_AUTH_TOKEN 값)" spellcheck="false" />
<button data-save="datacollect.bridgeNasToken">저장</button>
</div>
<small class="hint">NAS Bridge의 <code>x-bridge-token</code>. <strong>nas</strong> 타깃일 때만 요청 헤더에 실립니다.</small>
</div>
<div class="row"> <div class="row">
<label for="dcSavePath">결과물 저장 폴더</label> <label for="dcSavePath">결과물 저장 폴더</label>
<div class="input-group"> <div class="input-group">
+17
View File
@@ -25,7 +25,10 @@
const cnModelHint = $('cnModelHint'); const cnModelHint = $('cnModelHint');
// ---- Datacollect ---- // ---- Datacollect ----
const dcBridgeTarget = $('dcBridgeTarget');
const dcBridgeUrl = $('dcBridgeUrl'); const dcBridgeUrl = $('dcBridgeUrl');
const dcBridgeNasUrl = $('dcBridgeNasUrl');
const dcBridgeNasToken = $('dcBridgeNasToken');
const dcSavePath = $('dcSavePath'); const dcSavePath = $('dcSavePath');
const dcCrawlDepth = $('dcCrawlDepth'); const dcCrawlDepth = $('dcCrawlDepth');
const dcMaxPages = $('dcMaxPages'); const dcMaxPages = $('dcMaxPages');
@@ -125,9 +128,18 @@
); );
// ---- Datacollect listeners ---- // ---- 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', () => document.querySelector('[data-save="datacollect.bridgeUrl"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', bridgeUrl: dcBridgeUrl.value }) 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', () => document.querySelector('[data-save="datacollect.savePath"]').addEventListener('click', () =>
vscode.postMessage({ type: 'datacollect.update', savePath: dcSavePath.value }) vscode.postMessage({ type: 'datacollect.update', savePath: dcSavePath.value })
); );
@@ -385,7 +397,12 @@
// ---- Datacollect ---- // ---- Datacollect ----
const dc = state.datacollect; const dc = state.datacollect;
if (dc) { if (dc) {
if (dcBridgeTarget && document.activeElement !== dcBridgeTarget && (dc.bridgeTarget === 'local' || dc.bridgeTarget === 'nas')) {
dcBridgeTarget.value = dc.bridgeTarget;
}
setIfNotFocused(dcBridgeUrl, dc.bridgeUrl); setIfNotFocused(dcBridgeUrl, dc.bridgeUrl);
setIfNotFocused(dcBridgeNasUrl, dc.bridgeNasUrl);
setIfNotFocused(dcBridgeNasToken, dc.bridgeNasToken);
setIfNotFocused(dcSavePath, dc.savePath); setIfNotFocused(dcSavePath, dc.savePath);
setIfNotFocused(dcCrawlDepth, dc.crawlDepth); setIfNotFocused(dcCrawlDepth, dc.crawlDepth);
setIfNotFocused(dcMaxPages, dc.maxPages); setIfNotFocused(dcMaxPages, dc.maxPages);
+18 -2
View File
@@ -2,7 +2,7 @@
"name": "astra", "name": "astra",
"displayName": "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.", "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", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
@@ -204,10 +204,26 @@
"default": false, "default": false,
"description": "Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks." "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": { "g1nation.datacollectBridgeUrl": {
"type": "string", "type": "string",
"default": "http://127.0.0.1:3002", "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": { "g1nation.datacollectSavePath": {
"type": "string", "type": "string",
+29 -16
View File
@@ -4,19 +4,36 @@ import * as vscode from 'vscode';
* Datacollect (Wiki/Datacollect ) MCP Bridge HTTP . * Datacollect (Wiki/Datacollect ) MCP Bridge HTTP .
* *
* Bridge는 Node Express (`npm run bridge`) * Bridge는 Node Express (`npm run bridge`)
* 3002. Research(NotebookLM)/Web Benchmark(Playwright)/YouTube * 3002. Web Benchmark(Playwright)/YouTube(yt-dlp+transcript)/Wikify
* (yt-dlp+transcript) , Astra는 endpoint를 * , Astra는 endpoint를 thin client로
* thin client로 Playwright/Chrome/NotebookLM-MCP * Playwright/Chrome/Python Astra가 .
* Astra가 . * (NotebookLM Deep Research ASTRA Datacollect .)
* *
* URL은 `astra.datacollectBridgeUrl` VS Code override , * `g1nation.datacollectBridgeTarget`(`local`|`nas`) .
* `http://127.0.0.1:3002`. / . * - local(): `g1nation.datacollectBridgeUrl` ( `http://127.0.0.1:3002`)
* - nas: `g1nation.datacollectBridgeNasUrl` (+ `datacollectBridgeNasToken` )
* nas URL local ( ).
*/ */
export function getBridgeBaseUrl(): string { export function getBridgeBaseUrl(): string {
const raw = vscode.workspace.getConfiguration('g1nation').get<string>('datacollectBridgeUrl'); const cfg = vscode.workspace.getConfiguration('g1nation');
const url = (raw && raw.trim()) || 'http://127.0.0.1:3002'; const localUrl = (cfg.get<string>('datacollectBridgeUrl')?.trim()) || 'http://127.0.0.1:3002';
return url.replace(/\/$/, ''); 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+ . . * 5+ . .
* *
* : * :
* - research: NotebookLM Deep Research
* - youtube: yt-dlp + youtube-transcript-api * - youtube: yt-dlp + youtube-transcript-api
* - web: Playwright · * - web: Playwright ·
* - wiki: 생성된 * - wiki: 생성된
* - lm: 사용자의 LM Studio / Ollama LLM * - lm: 사용자의 LM Studio / Ollama LLM
*/ */
export const BRIDGE_API = { export const BRIDGE_API = {
research: { // research(NotebookLM)는 ASTRA 에서 제거됨(v2.2.205) — 로컬 Datacollect 앱 전용.
start: '/api/research/start',
status: '/api/research/status',
import: '/api/research/import',
synthesize: '/api/research/synthesize',
},
youtube: { youtube: {
extract: '/api/youtube/extract', extract: '/api/youtube/extract',
}, },
@@ -104,11 +115,13 @@ export async function bridgeFetch<T = any>(
} }
try { try {
const token = getBridgeAuthToken();
const res = await fetch(url, { const res = await fetch(url, {
...init, ...init,
signal: controller.signal, signal: controller.signal,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { 'x-bridge-token': token } : {}),
...(init?.headers || {}), ...(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) * v2.2.201 slashRouter.ts . Datacollect bridge (port 3002)
* NotebookLM Deep Research · Playwright · * NotebookLM Deep Research · Playwright ·
@@ -33,100 +34,6 @@ import {
parseActionItems, parseActionItems,
} from './scheduling/calendarHelpers'; } 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 ───────────────────────────── // ───────────────────────────── /benchmark ─────────────────────────────
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> { 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: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube }); registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog }); 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() const metaBlock = metadata.trim()
|| '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)'; || '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)';
return `# 임무 (Objective) return `# 임무 (Objective)
, ,
(Actionable Minutes) . (Actionable Minutes) . / *STT
* , * * .
# (Role) # (Role)
- Fact Extractor: 녹취록에 - Fact Extractor: 녹취록에
@@ -19,6 +20,14 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
# (Data Priority) # (Data Priority)
1순위: 메타데이터 / 2순위: 녹취록 . . 1순위: 메타데이터 / 2순위: 녹취록 . .
# STT (Transcription Noise Handling )
- (: "Dovrunner""Doverunner", "페어플레이""페어플래이"). ** ( ) .**
- · ****. (DRM, SDK, , API, , , ) .
- ··· ** ** , ( ).
- ** ( )**: '녹취록에 *있는* 단어의 철자를 문맥으로 바로잡는 것' ·. '녹취록에 *없는* 사실(수치·결정·없던 항목)을 지어내는 것' . ** .**
- ** "확인 불가" .**
- , 남긴다: ) \`Doverunner(원문: "Dovrunner", 표기 불확실)\`.
# (Processing Flow) # (Processing Flow)
1. Speaker Tracking ID/ . ** .** 1. Speaker Tracking ID/ . ** .**
2. Topic Reclustering (ABZ A ). ** ** . · (** **). 2. Topic Reclustering (ABZ A ). ** ** . · (** **).
@@ -34,8 +43,8 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
# · (Grounding Rules , ) # · (Grounding Rules , )
- ** .** , . - ** .** , .
- ** .** "(발언 주체 불명확)" "~라는 의견이 제시됨" . . - ** .** "(발언 주체 불명확)" "~라는 의견이 제시됨" . .
- ** ···· .** "확인 필요" . - ** ···· .** (, * * · 'STT 오타 보정' .) ** "확인 필요" .
- , "(확인 필요)" . - * * (= ) "(확인 필요)" . .
- Decision은 '결정됨'. '논의 중' . - Decision은 '결정됨'. '논의 중' .
# (Validation) # (Validation)
@@ -88,7 +88,13 @@ interface SettingsState {
polishPersonaOverride: string; polishPersonaOverride: string;
}; };
datacollect: { datacollect: {
/** 'local' | 'nas' — 어느 Bridge 인스턴스를 호출할지. */
bridgeTarget: string;
bridgeUrl: 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. */ /** Empty → results saved to the Bridge's WIKI_RAW_PATH default. */
savePath: string; savePath: string;
crawlDepth: number; crawlDepth: number;
@@ -605,9 +611,19 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
// savePath 가 비어 있으면 Bridge 의 WIKI_RAW_PATH 환경변수가 저장 위치를 결정한다. // savePath 가 비어 있으면 Bridge 의 WIKI_RAW_PATH 환경변수가 저장 위치를 결정한다.
private async _handleDatacollectUpdate(msg: any): Promise<void> { 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') { if (typeof msg.bridgeUrl === 'string') {
await this._safeConfigUpdate('datacollectBridgeUrl', msg.bridgeUrl.trim()); 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') { if (typeof msg.savePath === 'string') {
await this._safeConfigUpdate('datacollectSavePath', msg.savePath.trim()); await this._safeConfigUpdate('datacollectSavePath', msg.savePath.trim());
} }
@@ -675,7 +691,10 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
polishPersonaOverride: cfg.get<string>('polishPersonaOverride', '') ?? '', polishPersonaOverride: cfg.get<string>('polishPersonaOverride', '') ?? '',
}, },
datacollect: { datacollect: {
bridgeTarget: cfg.get<string>('datacollectBridgeTarget', 'local') || 'local',
bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '', bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '',
bridgeNasUrl: cfg.get<string>('datacollectBridgeNasUrl', '') || '',
bridgeNasToken: cfg.get<string>('datacollectBridgeNasToken', '') || '',
savePath: cfg.get<string>('datacollectSavePath', '') || '', savePath: cfg.get<string>('datacollectSavePath', '') || '',
crawlDepth: cfg.get<number>('datacollectCrawlDepth', 1) ?? 1, crawlDepth: cfg.get<number>('datacollectCrawlDepth', 1) ?? 1,
maxPages: cfg.get<number>('datacollectMaxPages', 8) ?? 8, 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) { if (after.missingPackages.length === 0) {
output.appendLine('\n✅ 설치 후 import 검증 통과. Datacollect 슬래시 명령을 바로 쓸 수 있습니다.'); output.appendLine('\n✅ 설치 후 import 검증 통과. Datacollect 슬래시 명령을 바로 쓸 수 있습니다.');
vscode.window.showInformationMessage( vscode.window.showInformationMessage(
`Astra Setup 완료: ${probe.missingPackages.join(', ')} 설치됨. /youtube /research 등 다시 시도해 보세요.`, `Astra Setup 완료: ${probe.missingPackages.join(', ')} 설치됨. /youtube 등 다시 시도해 보세요.`,
); );
} else { } else {
output.appendLine(`\n⚠️ pip 은 성공으로 끝났지만 import 검증에서 여전히 ${after.missingPackages.join(', ')} 가 안 보입니다.`); output.appendLine(`\n⚠️ pip 은 성공으로 끝났지만 import 검증에서 여전히 ${after.missingPackages.join(', ')} 가 안 보입니다.`);
+2 -2
View File
@@ -311,8 +311,8 @@ const HELP_CATEGORIES: HelpCategory[] = [
{ {
title: '리서치·분석', title: '리서치·분석',
emoji: '🔬', emoji: '🔬',
match: (n) => ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n), match: (n) => ['/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
blurb: 'Datacollect bridge 통합 — Deep Research / 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록', blurb: 'Datacollect bridge 통합 — 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록 (NotebookLM Deep Research 는 로컬 Datacollect 앱으로 분리)',
}, },
{ {
title: '시스템·메모리', title: '시스템·메모리',
+43 -2
View File
@@ -169,6 +169,12 @@ export class LongTermMemory {
.slice(0, 5); .slice(0, 5);
if (alwaysInclude.length === 0) return null; 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 const content = alwaysInclude
.map((e) => `- [${e.category}] ${e.content}`) .map((e) => `- [${e.category}] ${e.content}`)
.join('\n'); .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) { for (const { entry } of relevant) {
entry.lastReferencedAt = Date.now(); entry.lastReferencedAt = Date.now();
entry.referenceCount++; entry.referenceCount++;
if (entry.expiresAt) entry.expiresAt = refreshAt;
} }
this.dirty = true; this.dirty = true;
@@ -202,6 +211,34 @@ export class LongTermMemory {
// ─── Extraction Helpers ─── // ─── 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 . * LLM .
@@ -235,6 +272,8 @@ export class LongTermMemory {
for (const msg of messages) { for (const msg of messages) {
if (msg.role !== 'user') continue; if (msg.role !== 'user') continue;
const text = msg.content; const text = msg.content;
// 에러 로그/스택 트레이스 덤프는 '분석 대상'(휘발)이므로 통째로 채굴 제외.
if (LongTermMemory.looksLikeErrorLog(text)) continue;
for (const pattern of rulePatterns) { for (const pattern of rulePatterns) {
pattern.lastIndex = 0; pattern.lastIndex = 0;
@@ -269,9 +308,11 @@ export class LongTermMemory {
} }
} }
// Deduplicate by content // Deduplicate by content + 에러 시그니처가 박힌 후보 제거
// ('goal: fix ECONNREFUSED ...' 같은 에러 내용이 지식으로 흡수되는 오염 방지).
const seen = new Set<string>(); const seen = new Set<string>();
return candidates.filter((c) => { return candidates.filter((c) => {
if (LongTermMemory.ERROR_NOISE.test(c.content)) return false;
const key = c.content.toLowerCase(); const key = c.content.toLowerCase();
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
+6 -1
View File
@@ -38,13 +38,18 @@ export class MemoryExtractor {
}; };
// 1. Long-Term Memory 추출 // 1. Long-Term Memory 추출
// 자동 추출 항목엔 TTL(14일)을 부여 — 참조될 때마다 슬라이딩 연장되므로 실제로
// 쓰이는 지식은 살아남고, 한 번 들어온 일회성·잡음 내용은 14일 뒤 자연 소멸한다.
// (에러 로그/실패 데이터는 extractCandidates 단계에서 이미 걸러짐.)
const candidates = LongTermMemory.extractCandidates(messages); const candidates = LongTermMemory.extractCandidates(messages);
const expiresAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
for (const candidate of candidates) { for (const candidate of candidates) {
longTermMemory.addEntry( longTermMemory.addEntry(
candidate.category, candidate.category,
candidate.content, candidate.content,
`session:${sessionId}`, `session:${sessionId}`,
0.7 // 자동 추출이므로 기본 신뢰도 0.7 0.7, // 자동 추출이므로 기본 신뢰도 0.7
{ expiresAt },
); );
} }
result.longTermCandidates = candidates.length; result.longTermCandidates = candidates.length;
+17 -1
View File
@@ -90,6 +90,19 @@ export function selectWithinBudget(
return { selected, dropped, tokensUsed }; 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 const items = groupChunks
.map((c) => { .map((c) => {
const metadata = c.metadata; const metadata = c.metadata;
const subject = deriveSubject(c);
const subjectTag = subject ? `[${subject}] ` : '';
const conflictTag = metadata.conflictDetected ? ` [⚠️ CONFLICT: ${metadata.conflictSeverity}]` : ''; const conflictTag = metadata.conflictDetected ? ` [⚠️ CONFLICT: ${metadata.conflictSeverity}]` : '';
const coverageTag = metadata.queryCoverage !== undefined ? ` (Coverage: ${metadata.queryCoverage.toFixed(2)})` : ''; 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'); .join('\n');
sections.push(`### ${label}\n${items}`); sections.push(`### ${label}\n${items}`);
@@ -134,6 +149,7 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
return [ return [
'[MEMORY CONTEXT]', '[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.', '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') sections.join('\n\n')
].join('\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. 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. 3. [NO INTERNAL LOGS] Never output <details>, "2nd Brain Trace", or "Debug JSON" blocks.
4. [NO SECTION LEAKAGE] Never output sections named "요청 요약", "사용자 의도 추론", "프로젝트 기록 대상 확인", "핵심 확인 질문", or "근거 파일 경로". 4. [NO SECTION LEAKAGE] Never output sections named "요청 요약", "사용자 의도 추론", "프로젝트 기록 대상 확인", "핵심 확인 질문", or "근거 파일 경로".
5. [ ] · · (, , , , //, , "이미 ~다/~로 정해졌다" ) . "확인 불가" "근거 없음 — 확인 필요" . ("~로 보인다", "확인 필요"). , * * R7 '합리적 가정 후 진행' * * ( "가정:" ).
[OUTPUT FORMAT 7 hard rules] [OUTPUT FORMAT 7 hard rules]
These rules override any other formatting habit. Apply them to EVERY answer. These rules override any other formatting habit. Apply them to EVERY answer.