feat: v2.2.74 → v2.2.82 — chunked writer + 코드 리뷰 패치 + /youtube 확장

주요 변경:

[chunked writer 아키텍처 (v2.2.74~v2.2.75)]
- 5-stage 다중 에이전트(planner/researcher/reflector/writer/synthesizer)
  파이프라인 제거 → 단일 ChunkedWriter 의 outline → section[N] → polish
  3-step 으로 교체. 본문 분석에서 추상화 손실 / 토큰 폭증 문제 해소
- 답변 길이 자동 분기: 짧은 prompt 는 fast-path direct 1회 호출,
  본문 분석은 chunked. outline 빈 배열도 direct 폴백

[코드 리뷰 9개 항목 일괄 패치 (v2.2.76)]
- /research polling hang 방어 (heartbeat + status 정규화 + 연속 실패 abort)
- 회사 모드 dispatcher abort 신호를 AIService.chat 까지 전달
- bridgeFetch 에 onHeartbeat 콜백 도입 (slow endpoint 사용자 친화적)
- dead code 정리: reflectionPersister.ts 제거 + enableReflection 등 좀비 config 키
- parseOutline 의 empty vs fallback reason 명시적 분리
- chatHandlers 의 회사 모드 케이스 ~325줄을 src/sidebar/companyHandlers.ts 로 분리
- Intent Alignment 라운드 한도 도달 시 smart 모드 자동 진행
- LM Studio doSwitch unload 실패 시 currentModel 정리 + load 강행
- retrieval informationDensity → queryCoverage 정합화

[/youtube 채널 지원 (v2.2.77~v2.2.82)]
- 채널/플레이리스트 URL 자동 감지 + n:N 으로 영상 개수 지정 (최대 50)
- 채널 루트 URL 에 /videos 탭 자동 append (yt-dlp enumeration 정상화)
- 영상별 순차 처리 (queue 패턴) + i/N 진행 표시 + 마지막 통계 요약
- mode:info / mode:benchmark / mode:both 분석 모드 분기
  - info: 영상 내용을 지식 카드로 추출 (튜토리얼·강의·뉴스용)
  - benchmark: 4-렌즈 대본 역기획서 (콘텐츠 제작 벤치마크용)
  - both: 둘 다 (기본)
  - bare keyword 도 허용: /youtube <url> n:1 info
- bridge 에러 메시지 [object Object] 깨짐 수정 (구조화 에러 추출)
- "패키지 없음" 등 환경 의존성 에러에 자동 가이드 첨부

[Astra: Setup Datacollect Dependencies 명령 추가 (v2.2.80)]
- Python 자동 감지 + yt-dlp / youtube-transcript-api 자동 설치
- macOS PEP 668 환경 자동 폴백 (--user --break-system-packages)
- /youtube 등에서 패키지 미설치 감지 시 "Install Now" 버튼 notification

[테스트]
- tests/agentEngine.test.ts 를 chunked flow 에 맞춰 전체 재작성
- tests/resilience_stress.test.ts Scenario B/D 를 role-aware mock 으로 갱신
- 399/399 통과

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
g1nation
2026-05-23 23:13:21 +09:00
parent 0712014fcb
commit ded3eea7ce
39 changed files with 2098 additions and 1820 deletions
+46 -3
View File
@@ -22,6 +22,15 @@ export function getBridgeBaseUrl(): string {
export interface BridgeFetchOptions {
timeoutMs?: number;
signal?: AbortSignal;
/**
* 호출이 N ms 이상 지속되면 N ms 마다 한 번씩 호출되는 콜백. 긴 호출
* (synthesize / scan / import) 에서 사용자에게 "살아있다" 신호를 흘리려고
* 도입. 콜백은 fire-and-forget 으로 호출되며 예외는 silently swallow.
* 기본은 호출되지 않음.
*/
onHeartbeat?: (elapsedMs: number) => void;
/** heartbeat 간격 (ms). 미지정 시 30s. */
heartbeatMs?: number;
}
/**
@@ -48,6 +57,17 @@ export async function bridgeFetch<T = any>(
else opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
}
// Heartbeat — 긴 LLM synthesize / Playwright scan 도중에도 사용자에게
// "살아있다" 신호. 호출자가 onHeartbeat 안 줬으면 비활성.
const heartbeatStartedAt = Date.now();
let heartbeatInterval: NodeJS.Timeout | undefined;
if (opts.onHeartbeat) {
const intervalMs = Math.max(5_000, opts.heartbeatMs ?? 30_000);
heartbeatInterval = setInterval(() => {
try { opts.onHeartbeat!(Date.now() - heartbeatStartedAt); } catch { /* noop */ }
}, intervalMs);
}
try {
const res = await fetch(url, {
...init,
@@ -63,13 +83,35 @@ export async function bridgeFetch<T = any>(
if (!res.ok) {
const stage = body?.stage ? `[${body.stage}] ` : '';
const errMsg = body?.error || body?.message || (typeof body === 'string' ? body : `HTTP ${res.status}`);
throw new Error(`Datacollect ${path} 실패: ${stage}${errMsg}`);
// Bridge 가 에러 body 를 객체로 보낼 때 (e.g. `{error: {message, code, details}}`)
// 옛 포맷터는 `body.error` 가 객체면 `${}` 보간이 `[object Object]` 로 깨져
// 사용자가 실제 원인 메시지를 못 봄. 문자열 추출을 우선순위대로 시도:
// 1) body.error.message (구조화된 에러)
// 2) body.error (문자열일 때)
// 3) body.message (외곽 message)
// 4) body 가 통째로 문자열
// 5) JSON.stringify(body.error) (최후 — 구조 그대로 노출)
// 6) HTTP status 만
const extractErr = (): string => {
if (body?.error?.message && typeof body.error.message === 'string') return body.error.message;
if (typeof body?.error === 'string') return body.error;
if (typeof body?.message === 'string') return body.message;
if (typeof body === 'string') return body;
if (body?.error) {
try { return JSON.stringify(body.error).slice(0, 400); } catch { /* fall through */ }
}
return `HTTP ${res.status}`;
};
throw new Error(`Datacollect ${path} 실패: ${stage}${extractErr()}`);
}
return body as T;
} catch (e: any) {
if (e?.name === 'AbortError') {
throw new Error(`Datacollect ${path} 시간 초과 (${timeoutMs}ms). Bridge가 떠 있는지 확인하세요 (${base}).`);
// 외부 signal 로 인한 abort 인지 timeout 인지 구분해서 안내.
if (opts.signal?.aborted) {
throw new Error(`Datacollect ${path} 취소됨 (사용자 abort).`);
}
throw new Error(`Datacollect ${path} 시간 초과 (${timeoutMs}ms). Bridge가 응답하지 않습니다 (${base}).`);
}
// ECONNREFUSED 등 connect 실패는 친절히 안내.
const msg = String(e?.message || e);
@@ -82,5 +124,6 @@ export async function bridgeFetch<T = any>(
throw e;
} finally {
clearTimeout(timer);
if (heartbeatInterval) clearInterval(heartbeatInterval);
}
}