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:
@@ -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 || {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(', ')} 가 안 보입니다.`);
|
||||
|
||||
@@ -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: '시스템·메모리',
|
||||
|
||||
Reference in New Issue
Block a user