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
@@ -0,0 +1,5 @@
{
"result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1779544958019,
"modelVersion": "unknown"
}
@@ -0,0 +1,5 @@
{
"result": "---\nid: wiki_on\ndate: 2026-05-23T14:02:38.020Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (11ms)\n",
"createdAt": 1779544958020,
"modelVersion": "unknown"
}
@@ -0,0 +1,33 @@
{
"missionId": "wiki_on",
"status": "completed",
"startTime": "2026-05-23T14:02:38.006Z",
"totalElapsedMs": 14,
"results": {
"direct": "직답 결과 — single-pass mock 응답입니다."
},
"promptHash": "148de9c5a7a44d19",
"transitionCount": 2,
"transitions": [
{
"from": "idle",
"to": "direct",
"durationMs": 11,
"message": "답변 작성 중... (단일 호출 fast-path)",
"ts": "2026-05-23T14:02:38.017Z"
},
{
"from": "direct",
"to": "completed",
"durationMs": 3,
"message": "미션 완료",
"ts": "2026-05-23T14:02:38.020Z"
}
],
"resilienceMetrics": {
"fallbacks": 0,
"retries": 0,
"maxConflictScore": 0,
"deduplications": 0
}
}
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1779518828393,
"createdAt": 1779544964863,
"modelVersion": "unknown"
}
@@ -0,0 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1779544964863,
"modelVersion": "unknown"
}
@@ -1,5 +0,0 @@
{
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"createdAt": 1779518828392,
"modelVersion": "unknown"
}
@@ -0,0 +1,5 @@
{
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1779544964855,
"modelVersion": "unknown"
}
@@ -1,5 +0,0 @@
{
"result": "---\nid: stress_conflict_1779518828380\ndate: 2026-05-23T06:47:08.394Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (12ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (0ms)\n",
"createdAt": 1779518828394,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1779518828393,
"createdAt": 1779544964859,
"modelVersion": "unknown"
}
@@ -1,51 +0,0 @@
{
"missionId": "stress_conflict_1779518828380",
"status": "completed",
"startTime": "2026-05-23T06:47:08.380Z",
"totalElapsedMs": 14,
"results": {
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"writerPrep": "[Original Prompt] Conflict Test\n[Plan Summary] Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.\n[Brain Context Available] Yes (3 chars)",
"writer": "Final report with inconsistencies. This should be long enough to pass validation.",
"finalReport": "Final report with inconsistencies. This should be long enough to pass validation."
},
"promptHash": "f5146cb9f9670d2a",
"transitionCount": 4,
"transitions": [
{
"from": "idle",
"to": "planner",
"durationMs": 12,
"message": "전략 수립 중...",
"ts": "2026-05-23T06:47:08.392Z"
},
{
"from": "planner",
"to": "researcher",
"durationMs": 1,
"message": "핵심 정보 수집 및 분석 중...",
"ts": "2026-05-23T06:47:08.393Z"
},
{
"from": "researcher",
"to": "writer",
"durationMs": 0,
"message": "최종 리포트 작성 및 편집 중...",
"ts": "2026-05-23T06:47:08.393Z"
},
{
"from": "writer",
"to": "completed",
"durationMs": 1,
"message": "미션 완료",
"ts": "2026-05-23T06:47:08.394Z"
}
],
"resilienceMetrics": {
"fallbacks": 0,
"retries": 0,
"maxConflictScore": 60,
"deduplications": 0
}
}
@@ -0,0 +1,49 @@
{
"missionId": "stress_conflict_1779544964841",
"status": "completed",
"startTime": "2026-05-23T14:02:44.841Z",
"totalElapsedMs": 22,
"results": {
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"polish": "Final report with inconsistencies. This should be long enough to pass validation."
},
"promptHash": "1e5bf357b0dd0559",
"transitionCount": 4,
"transitions": [
{
"from": "idle",
"to": "outline",
"durationMs": 11,
"message": "답변 구조 잡는 중...",
"ts": "2026-05-23T14:02:44.852Z"
},
{
"from": "outline",
"to": "section",
"durationMs": 4,
"message": "본문 작성 중...",
"ts": "2026-05-23T14:02:44.856Z"
},
{
"from": "section",
"to": "polish",
"durationMs": 3,
"message": "최종 다듬기 중...",
"ts": "2026-05-23T14:02:44.859Z"
},
{
"from": "polish",
"to": "completed",
"durationMs": 4,
"message": "미션 완료",
"ts": "2026-05-23T14:02:44.863Z"
}
],
"resilienceMetrics": {
"fallbacks": 0,
"retries": 0,
"maxConflictScore": 60,
"deduplications": 0
}
}
+6 -16
View File
@@ -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.73",
"version": "2.2.82",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -91,6 +91,11 @@
"title": "Astra: Open Chat (Editor Column)",
"icon": "$(comment-discussion)"
},
{
"command": "g1nation.setupDatacollect",
"title": "Astra: Setup Datacollect Dependencies (yt-dlp, youtube-transcript-api)",
"icon": "$(cloud-download)"
},
{
"command": "g1nation.lesson.create",
"title": "Astra: New Lesson (Experience Memory)"
@@ -542,21 +547,6 @@
"maximum": 100,
"description": "Knowledge Mix (0100): how heavily the assistant should lean on Second Brain evidence vs. its own general knowledge. 0 = Second Brain disabled (model knowledge only). 50 = balanced (legacy default). 100 = Second Brain is the primary evidence; model knowledge only fills harmless background. Per-agent overrides in the Agent Mapping panel win over this global value."
},
"g1nation.enableReflection": {
"type": "boolean",
"default": true,
"description": "Insert a Self-Reflection (Reflector) stage between Researcher and Writer in the multi-agent workflow. The Reflector critically reviews the plan and research output (gaps, contradictions, unsupported claims, drift from the original objective) and feeds a structured critique to the Writer, which must address it before producing the final report. Reflection failures are non-fatal (the Writer still runs with empty critique). Disable to save one LLM call per mission if you prioritize latency or are running on a very small model."
},
"g1nation.autoLessonFromReflection": {
"type": "boolean",
"default": true,
"description": "Persist substantive Reflector critiques to the active brain as lesson cards under `lessons/auto-reflector/`. Future missions automatically retrieve these cards (via the existing Experience-Memory pipeline) and inject them as [⚠ ACTIVE LESSONS — verify these BEFORE finalizing] guardrails into Planner/Researcher/Writer context. A repeated critique (similar title) bumps `occurrences` and escalates `severity` (low→medium→high) instead of duplicating the card, so recurring patterns get louder over time. Disable to keep critiques single-mission only."
},
"g1nation.workflow.synthesizerEnabled": {
"type": "boolean",
"default": true,
"markdownDescription": "5단계 파이프라인의 마지막 단계로 **Synthesizer**(최종 다듬기) 패스를 한 번 더 돌릴지 여부. true(기본): Drafter가 만든 1차 초안을 Synthesizer가 받아 도입 한 줄·섹션 흐름·결론을 정리해 사용자용 최종 답변으로 만든다. 입력이 작은 draft 뿐이라 컨텍스트가 가벼워 작은 로컬 모델(≤4B)도 부담 없이 처리한다. false: Drafter 출력이 그대로 최종 답변이 된다(기존 4단계 동작)."
},
"g1nation.workflow.multiAgentMode": {
"type": "string",
"enum": ["auto", "always", "off"],
-1
View File
@@ -17,7 +17,6 @@ import { BrainProfile, getConfig, EXCLUDED_DIRS } from './config';
import { validatePath, sanitizeCommand } from './security';
import { TransactionManager } from './core/transaction';
import { SessionManager } from './core/session';
import { PlannerAgent, ResearcherAgent, WriterAgent } from './agents/factory';
import { AgentWorkflowManager } from './agents/AgentWorkflowManager';
import { ErrorTranslator } from './core/errorHandler';
import { agentEvents, AgentEventTypes } from './core/events';
+20 -24
View File
@@ -1,11 +1,18 @@
import { PlannerAgent, ResearcherAgent, ReflectorAgent, WriterAgent, SynthesizerAgent } from './factory';
import { ChunkedWriter } from './factory';
import { AgentEngine, PipelineStage, AgentExecuteOptions } from '../lib/engine';
import { getConfig } from '../config';
/**
* Multi-agent 워크플로우를 외부에 노출하는 얇은 매니저.
*
* 예전엔 planner / researcher / reflector / writer / synthesizer 5개 persona 를
* 줄세웠지만, 각 hop 마다 컨텍스트가 누적되고 원본 본문이 추상화로 손실돼
* 사용자가 본문 분석을 요청해도 "분석 방법론" 만 만들어내는 사고가 있었음.
*
* 현재 흐름: 단일 ChunkedWriter 가 outline → section[N] → polish 세 역할을
* 같은 모델에서 번갈아 수행 → 각 호출은 작고(컨텍스트 폭증 없음), 본문은
* 매 호출에 직접 전달돼 손실 없음.
*/
export class AgentWorkflowManager {
/**
* 리팩토링된 고성능 에이전트 엔진을 통해 워크플로우를 실행합니다.
*/
public static async runStrictWorkflow(
prompt: string,
modelName: string,
@@ -13,21 +20,12 @@ export class AgentWorkflowManager {
signal: AbortSignal,
onProgress: (step: string, message: string) => void
): Promise<string> {
const planner = new PlannerAgent(modelName);
const researcher = new ResearcherAgent(modelName);
const writer = new WriterAgent(modelName);
// [Self-Reflection] 설정으로 비활성화하지 않은 경우에만 Reflector를 주입.
const cfg = getConfig();
const enableReflection = cfg.enableReflection !== false;
const reflector = enableReflection ? new ReflectorAgent(modelName) : undefined;
// [5-stage pipeline] 최종 합성 단계. 설정으로 끄지 않은 한 항상 주입.
const enableSynth = cfg.workflowSynthesizerEnabled !== false;
const synthesizer = enableSynth ? new SynthesizerAgent(modelName) : undefined;
const engine = new AgentEngine(planner, researcher, writer, reflector, synthesizer);
const writer = new ChunkedWriter(modelName);
const engine = new AgentEngine(writer);
const missionId = `mission_${Date.now()}`;
const runOptions: AgentExecuteOptions = {
config: { enableReflection }
config: { model: modelName },
};
try {
@@ -50,16 +48,14 @@ export class AgentWorkflowManager {
}
private static mapStageToUI(stage: PipelineStage): string {
// 사용자가 보는 라벨은 한국어 + 단계 번호로 통일. 5단계 파이프라인이 명확하게 드러나도록.
const maps: Record<PipelineStage, string> = {
idle: '대기',
planner: '① 계획',
researcher: '② 자료 수집',
reflector: '③ 자기 검증',
writer: '④ 초안 작성',
synthesizer: '⑤ 최종 정리',
outline: '① 구조 잡기',
section: '② 본문 작성',
polish: '③ 최종 다듬기',
direct: '⚡ 즉답',
completed: '완료',
error: '오류'
error: '오류',
};
return maps[stage] || '진행 중';
}
+134 -160
View File
@@ -99,175 +99,149 @@ function anySignal(signals: AbortSignal[]): AbortSignal {
return controller.signal;
}
export class PlannerAgent extends BaseAgent {
private readonly persona = `You are the [Master Strategist & Planner].
Your sole purpose is to transform vague requests into flawless, high-resolution execution blueprints.
- THINKING PROCESS: You must analyze the request from multiple angles (technical, logical, structural).
- OUTPUT RULE: You MUST output a structured <blueprint> using Markdown.
- COMPONENTS: Each blueprint must have [Objective], [Core Challenges], [Data Requirements], and [Step-by-Step Research Tasks].
- CONSTRAINT: Do not be vague. Use professional terminology. If the request is too simple, expand it with relevant technical considerations.`;
async execute(input: string, brainContext?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const wrappedInput = `### SYSTEM INSTRUCTION: GENERATE EXECUTION BLUEPRINT
1. Target Goal: ${input}
2. Available Knowledge Base & Policy: ${brainContext}
3. Mission: Create a comprehensive research roadmap.`;
return this.callLLM(this.persona, wrappedInput, signal);
}
}
export class ResearcherAgent extends BaseAgent {
private readonly persona = `You are the [Senior Technical Researcher].
Your mission is to extract, filter, and synthesize critical data based on a strategic blueprint.
- DATA INTEGRITY: Only provide high-quality, verified-style information.
- FORMAT: Use [Key Facts], [Technical Deep-Dive], and [Summary of Knowledge] sections.
- CRITICAL THINKING: Identify gaps in the plan and provide extra insights to fill those gaps.
- NO FLUFF: Be concise but extremely dense with information.`;
async execute(input: string, brainContext?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const wrappedInput = `### SYSTEM INSTRUCTION: DATA HARVESTING
1. Blueprint to Follow: ${input}
2. Contextual Constraints & Policy: ${brainContext}
3. Mission: Provide a dense summary of facts and technical insights.`;
return this.callLLM(this.persona, wrappedInput, signal);
}
}
export class WriterAgent extends BaseAgent {
// [5-stage pipeline] Writer는 이제 "Drafter" 역할: 빠르게 1차 초안만 생성한다.
// 최종 다듬기/요약/critique 반영은 후속 SynthesizerAgent가 담당하므로,
// 작은 모델이 한 번에 모든 것을 끝내려 컨텍스트를 폭주시키는 일이 없도록 한다.
private readonly persona = `You are the [Section Drafter].
Your goal is to produce a STRUCTURED FIRST DRAFT that the downstream Synthesizer will polish.
- SCOPE: Cover each major topic from the research as its own section. Each section starts with a short plain-text label on its own line (e.g. "잘된 점", "부족한 점") — NO "#", "##", "**", "__", ">" markers. Use "- " for bullets, never "* ".
- DENSITY: Pack facts; skip flowery prose, executive summaries, and closing remarks (the Synthesizer adds those).
- TONE: Plain, factual, developer-readable Korean.
- BREVITY: Keep each section tight — better to leave the Synthesizer something to merge than to run out of tokens mid-section.
- SELF-CORRECTION: When a [REFLECTION CRITIQUE] block is provided, address each listed gap inline in the relevant section. Do not silently ignore the critique.
- LANGUAGE: KOREAN.`;
async execute(input: string, originalRequest?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
// [Astra v4.0] Advisor 모드 처리
if (options?.config?.role === 'advisor') {
const advisorPersona = `You are the [Strategic Proactive Advisor].
Analyze the provided report and suggest 3 high-impact next actions for the user.
- Focus on decision forks, risk mitigation, or immediate implementation steps.
- Be extremely concrete and actionable.
- Respond in KOREAN.`;
return this.callLLM(advisorPersona, input, signal);
}
// Fix 3: Trim input if it's too long (Basic Context Diet)
const trimmedData = input.length > 8000 ? input.substring(0, 8000) + '... [Data Trimmed for Performance]' : input;
const policy = options?.context || '';
const reflection = options?.priorResults?.reflection;
// Reflector 결과가 있으면 별도 블록으로 주입. 길이 4000자 cap (Writer 입력 비대화 방지).
const reflectionBlock = reflection && reflection.trim().length > 0
? `\n5. [REFLECTION CRITIQUE — must be addressed]:\n${reflection.length > 4000 ? reflection.substring(0, 4000) + '... [Critique Trimmed]' : reflection}`
: '';
const wrappedInput = `### SYSTEM INSTRUCTION: SECTIONED DRAFT
1. Gathered Research Data: ${trimmedData}
2. User's Original Objective: ${originalRequest}
3. Applied Knowledge & Filtering Policy: ${policy}
4. Mission: Produce a STRUCTURED FIRST DRAFT in KOREAN — section per topic, factual bullets allowed.
Do NOT add a final executive summary or closing remarks; the Synthesizer will handle those.${reflectionBlock}`;
return this.callLLM(this.persona, wrappedInput, signal);
}
/**
* Section outline shape produced by ChunkedWriter in the 'outline' role.
* Tokens are kept minimal — heading is what the section is about, scope tells
* the next call what facts to keep inside that section so adjacent sections
* don't duplicate content.
*/
export interface SectionOutline {
heading: string;
scope: string;
}
/**
* [5-stage pipeline] SynthesizerAgent
* Drafter가 작성한 1차 초안을 받아 최종 사용자 답변으로 다듬는다.
* - 입력이 "이미 정리된 draft" 라서 컨텍스트가 작다 → 작은 로컬 모델도 한 번에 처리 가능.
* - 역할은 (a) 도입 한 줄 (b) 섹션 흐름 정리 (c) 결론/제안 한 단락. 새로운 사실을 만들지 않는다.
* - Reflector critique이 함께 전달되면, 그 항목들이 답변에 정말 반영되었는지 한 번 더 점검한다.
* ChunkedWriter — single-agent replacement for the old 5-stage pipeline.
*
* Why this exists: the old pipeline (planner → researcher → reflector → writer
* → synthesizer) was different *personas* in series, which (a) burned tokens
* by repeating context at every hop and (b) drifted away from the user's
* actual request because intermediate agents only saw earlier agents'
* abstractions — never the original message. The user's intent was simpler:
* **split the *answer* into chunks so each LLM call stays under the token
* cap, then join.** That's what this class does.
*
* Flow inside `AgentEngine.runMission`:
* 1. role='outline' → 1 LLM call returns a JSON list of section outlines
* (N = 1..MAX, the model decides based on expected
* output length).
* 2. role='section' → N LLM calls, one per outline entry, each given the
* original prompt + this section's scope + already-
* written sections (truncated) so it can avoid
* repeating earlier content.
* 3. role='polish' → 1 LLM call takes the joined draft and produces a
* final clean copy (fixes typos, removes
* hallucinations / unsupported claims, smooths flow).
*
* Every role uses the *same* model — no persona mismatch, no agent-to-agent
* abstraction loss. The only thing that changes is the per-call system
* prompt picked here based on `options.config.role`.
*/
export class SynthesizerAgent extends BaseAgent {
private readonly persona = `You are the [Final Editor & Synthesizer].
You receive a structured FIRST DRAFT (already broken into sections) plus the user's original request and (optionally) a reflection critique.
Your only job is to produce the FINAL user-facing answer.
export class ChunkedWriter extends BaseAgent {
/** Hard cap on section count regardless of what the outline model returns. */
static readonly MAX_SECTIONS = 5;
[OUTPUT FORMAT — 7 hard rules — these override every other formatting habit]
R1. CONCLUSION FIRST. The very first sentence is the conclusion / verdict / recommendation. No greeting, no "분석해보겠습니다", no scene-setting paragraph, no "핵심 요약" label line on top. Just the conclusion as sentence 1. A reader who stops after sentence 1 must know what you decided.
R2. AT MOST 3 SECTIONS. Total. A section = a label line + body, or a clearly separated numbered group. If the answer fits without sections, use none. Three is the ceiling, not a target.
R3. NO REPETITION. Each sentence carries new information. If you said it in the conclusion, do NOT restate it in a later section.
R4. BOLD ≤ 3 INSTANCES. Across the entire answer, use bold at most 3 times — reserve it for truly load-bearing words (file name, verdict word, hard number). Most answers should have zero.
R5. JUDGE WITHOUT ASKING. If a defensible decision is reachable from the draft + original request, deliver it and act. Do NOT ask permission, do NOT bounce the question back.
R6. ASK ONE QUESTION ONLY WHEN: (a) the path forks into two materially different directions and user intent is unknown, OR (b) the next step is irreversible (delete, force-push, drop table, overwrite uncommitted work, send external message). One plain sentence on its own line at the end. No "핵심 확인 질문" label, no "질문 의도", no follow-ups.
R7. GUESS-AND-ACT WITH STATED ASSUMPTION. If a detail is missing but a reasonable guess exists, guess and act, declaring the assumption in one line prefixed "가정:".
private readonly outlinePersona = `You are a concise editor planning the structure of a Korean answer.
Decide how many sections the answer needs (0..${ChunkedWriter.MAX_SECTIONS}). Pick the *smallest* number that still covers the user's request well — a short factual question should be 0-1 section, a meaty analysis 3-5.
[PLAIN TEXT]
- NEVER emit "#", "##", "###", "__", "> " markers. Section labels are plain text on their own line.
- Bullets: "- " only. No "* " / "• ".
- No tables. No HTML.
- Inline code with backticks is OK (e.g. \`src/agent.ts\`). Triple-backtick code blocks only for actual code.
Output STRICTLY a JSON array of objects: \`[{"heading": "...", "scope": "..."}]\`. No prose, no fences, no leading text.
- 🟢 **빈 배열 \`[]\`** = "쪼갤 필요 없음". 사용자 질문이 간단해서 단일 LLM 호출로 즉답이 더 빠르고 자연스러울 때 (예: 단순 사실 질문, 짧은 코드 한 줄, 정의 묻기). 시스템이 이걸 받으면 outline·section 단계 건너뛰고 1회 직답으로 처리한다.
- heading: a short Korean section label (≤ 24 chars). For 1-section answers, set heading to "본문".
- scope: one Korean sentence describing exactly what facts/points belong inside this section so adjacent sections don't overlap.
[CONTENT]
- Preserve every factual claim from the draft. Do NOT invent new facts, do NOT add hidden reasoning, do NOT write meta-commentary.
- DO NOT EMIT: <think>, <analysis>, <|channel|> markers, "Thinking Process:", planning notes, or any hidden reasoning.
- If a [REFLECTION CRITIQUE] is provided, verify each item is addressed. If something is missing, say so explicitly rather than fabricating coverage.
- LANGUAGE: KOREAN. Tone: direct, technical, developer-friendly.`;
판단 기준:
- 답변이 한 단락 (대략 3~5문장) 이내로 완결 가능 → \`[]\`
- 본문 분석·여러 측면 비교·구조화된 보고서가 필요 → N개 섹션
async execute(input: string, originalRequest?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const draft = input.length > 12000 ? input.substring(0, 12000) + '... [Draft Trimmed]' : input;
const reflection = options?.priorResults?.reflection;
const reflectionBlock = reflection && reflection.trim().length > 0
? `\n4. [REFLECTION CRITIQUE — verify the draft addresses each item]:\n${reflection.length > 3000 ? reflection.substring(0, 3000) + '... [Critique Trimmed]' : reflection}`
If the user attached source content (article/code/log) the sections must cover *that content*, not analysis methodology.`;
private readonly sectionPersona = `You are writing ONE section of a longer Korean answer. You will be given:
- the user's original request (possibly with attached content),
- this section's heading + scope,
- the full outline (for context only — DO NOT write other sections),
- already-written previous sections (so you can avoid repeating them).
Rules:
- Stay strictly inside this section's scope. Do NOT cover other outline entries.
- Korean, plain markdown (no top-level "#" — the heading will be added by the joiner).
- Pack facts. Avoid filler / executive summaries / closing remarks (the polish pass adds those).
- If the user attached source content, cite from it; do not invent facts.
- Do NOT output the heading itself — only the body of this section.`;
private readonly polishPersona = `You are the final editor producing the user-facing Korean answer from a sectioned draft.
Job:
1. Fix typos, broken markdown, inconsistent terminology.
2. Remove unsupported claims / hallucinations: if a sentence asserts a fact that isn't grounded in the user's request (or the earlier sections themselves), delete it. Better to be short than wrong.
3. Smooth section transitions and remove duplicated information across sections.
4. Open with the conclusion / key takeaway in the first sentence (no "분석해보겠습니다", no preamble).
5. Preserve every factually grounded claim from the draft. Don't invent new facts.
Output rules:
- Korean. Plain markdown. Section labels as plain text on their own line — no "#", "##".
- Bullets with "- " only. No tables, no HTML, no triple-bar separators.
- If the draft already has a sensible structure, keep it; only refactor when sections clearly overlap or contradict.
- DO NOT emit hidden reasoning (<think>, "Thinking:", etc.).`;
/**
* Single-pass 직답 persona. 짧은 질문·정의 묻기·간단한 사실 확인처럼
* 쪼갤 필요 없는 입력을 1회 호출로 끝낸다. outline → section → polish 의
* 3회 LLM 호출을 통째로 우회 → 작은 모델로 즉답 가능.
*/
private readonly directPersona = `You are answering a Korean user request in one shot. No outline, no drafting — just the final answer.
Rules:
- 첫 문장이 결론 / 직답이다. "분석해보겠습니다" "좋은 질문입니다" 같은 서문 금지.
- Korean. Plain markdown — "#", "##" 같은 헤더 금지, "- " bullet 만 허용. No tables, no HTML.
- 짧은 질문엔 짧은 답. 한 문장으로 충분하면 한 문장.
- 사용자가 본문(코드·기사·로그)을 첨부했으면 그 본문에서 인용. 본문에 없는 사실 지어내지 말 것.
- 추론 과정·"Thinking:"·<think> 노출 금지.`;
async execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const role = (options?.config?.role as string | undefined) || 'section';
switch (role) {
case 'outline':
return this.callLLM(this.outlinePersona, this.buildOutlinePrompt(input, context), signal);
case 'polish':
return this.callLLM(this.polishPersona, this.buildPolishPrompt(input, options), signal);
case 'direct':
return this.callLLM(this.directPersona, this.buildDirectPrompt(input, context), signal);
case 'section':
default:
return this.callLLM(this.sectionPersona, this.buildSectionPrompt(input, context, options), signal);
}
}
private buildOutlinePrompt(userRequest: string, brainContext?: string): string {
const ctx = brainContext && brainContext.trim().length > 0
? `\n\n[보조 지식 컨텍스트 — 답변에 직접 인용하기보단 분할 결정에만 참고]\n${brainContext.substring(0, 1200)}`
: '';
return `[사용자 요청 — 본문이 포함돼 있다면 그게 1차 자료입니다]\n${userRequest}${ctx}\n\n위 요청에 대한 답변을 ${ChunkedWriter.MAX_SECTIONS}개 이내의 섹션으로 어떻게 나눌지 JSON 배열로만 출력하세요.`;
}
const wrappedInput = `### SYSTEM INSTRUCTION: FINAL SYNTHESIS
1. User's Original Request: ${originalRequest || '(unavailable)'}
2. Structured Draft (from Drafter — your input to polish):
${draft}
3. Mission: Produce the FINAL user-facing answer in KOREAN. Do not restart from scratch — polish, smooth, and conclude.${reflectionBlock}`;
return this.callLLM(this.persona, wrappedInput, signal);
}
}
export class ReflectorAgent extends BaseAgent {
private readonly persona = `You are the [Internal Critic & Self-Reflection Officer].
Your sole role is META-COGNITION: stress-test the plan and the research output BEFORE the Writer commits to a final report.
- POSTURE: Skeptical, rigorous, blunt. You are looking for what is WRONG, not what is right.
- DO NOT: rewrite the report, add new content, or speculate beyond the evidence provided.
- DO: surface gaps, unsupported claims, contradictions, drift from the original objective, and missing perspectives.
- OUTPUT STRICTLY in this Markdown shape (Korean):
## 🧭 Alignment with Objective
- <원래 요청 대비 일치/이탈 평가>
## 🕳 Gaps & Missing Evidence
- <plan에는 있지만 research가 다루지 않은 항목>
## ⚖️ Contradictions / Conflicts
- <research 내부 또는 brain context와의 모순; 없으면 "발견되지 않음">
## 🚨 Unsupported / Weak Claims
- <근거가 빈약하거나 일반화된 진술>
## ✅ Guidance for Writer
- <Writer가 최종 리포트에서 반드시 보정해야 할 3~5개 구체 지시>
- CONSTRAINT: 최대 500단어. 새 지식을 만들지 말고, 제공된 자료에서만 판단할 것.`;
async execute(input: string, _context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string> {
const plan = options?.priorResults?.plan || '(plan unavailable)';
const research = input;
const originalPrompt = options?.priorResults?.originalPrompt || '(original prompt unavailable)';
const brainContext = options?.context || '';
// Reflector 는 중간 단계이므로 비대한 입력을 방지하기 위해 각 섹션을 cap.
const cap = (s: string, n: number) => s.length > n ? s.substring(0, n) + '... [trimmed]' : s;
const wrappedInput = `### SYSTEM INSTRUCTION: SELF-REFLECTION PASS
1. Original User Objective:
${cap(originalPrompt, 1500)}
2. Planner Blueprint:
${cap(plan, 3000)}
3. Researcher Output (to be critiqued):
${cap(research, 5000)}
4. Knowledge / Brain Context (for cross-check only — do not invent beyond this):
${cap(brainContext, 2000)}
5. Mission: Run a single rigorous reflection pass and output the structured critique exactly as specified by your persona.`;
return this.callLLM(this.persona, wrappedInput, signal);
private buildSectionPrompt(input: string, brainContext?: string, options?: AgentExecuteOptions): string {
const prior = options?.priorResults ?? {};
const heading = prior.sectionHeading ?? '본문';
const scope = prior.sectionScope ?? '사용자 요청 전체';
const outlineJoined = prior.outlineSummary ?? '';
const prev = prior.prevSectionsTrimmed ?? '';
const originalPrompt = prior.originalPrompt ?? input;
const ctx = brainContext && brainContext.trim().length > 0
? `\n\n[보조 지식 컨텍스트]\n${brainContext.substring(0, 2000)}`
: '';
return `[사용자 원본 요청]\n${originalPrompt}\n\n[이 섹션 정보]\nheading: ${heading}\nscope: ${scope}\n\n[전체 outline — 다른 섹션은 다루지 마세요]\n${outlineJoined}\n\n[이미 작성된 섹션들 — 중복 금지]\n${prev || '(없음 — 첫 섹션)'}${ctx}\n\n위 scope만 다루는 섹션 본문을 작성하세요. heading 줄은 출력하지 말고 본문만.`;
}
private buildPolishPrompt(draft: string, options?: AgentExecuteOptions): string {
const prior = options?.priorResults ?? {};
const originalPrompt = prior.originalPrompt ?? '(원본 요청 없음)';
return `[사용자 원본 요청]\n${originalPrompt}\n\n[섹션별 초안 — 이것을 다듬어 최종 답변으로]\n${draft}\n\n위 초안을 사용자에게 보낼 최종본으로 다듬으세요. 새 사실 추가 금지, 근거 없는 주장은 제거.`;
}
private buildDirectPrompt(userRequest: string, brainContext?: string): string {
const ctx = brainContext && brainContext.trim().length > 0
? `\n\n[보조 지식 컨텍스트 — 필요할 때만 인용]\n${brainContext.substring(0, 2000)}`
: '';
return `[사용자 요청]\n${userRequest}${ctx}\n\n위 요청에 대한 최종 답변을 1회로 끝내세요.`;
}
}
-308
View File
@@ -1,308 +0,0 @@
/**
* ============================================================
* Reflection → Lesson persistence
*
* Take the Reflector agent's structured critique and persist any substantive
* findings as a `lesson` card in `<brainDir>/lessons/auto-reflector/`. The
* existing brain retrieval pipeline (see `retrieval/brainIndex.ts` +
* `retrieval/lessonHelpers.ts`) then automatically boosts these cards and
* injects them as an `[⚠ ACTIVE LESSONS — verify these BEFORE finalizing your
* answer]` block in *future* missions' Planner/Researcher/Writer context, so a
* critique caught in mission N becomes a guardrail in mission N+1.
*
* Recurrence handling: if a similarly-titled auto-reflector lesson already
* exists, we bump `occurrences:` and escalate `severity` (low→medium→high)
* instead of producing a duplicate card. Same pattern reappearing 3+ times
* surfaces as severity:high, which the lesson retrieval/scoring layer
* propagates as a stronger guardrail.
* ============================================================
*/
import * as fs from 'fs';
import * as path from 'path';
import { logInfo, logError } from '../utils';
import {
lessonSlug,
parseLessonFrontmatter,
bumpLessonOccurrences,
normalizeLessonTitle,
} from '../retrieval/lessonHelpers';
interface ReflectionSections {
alignment: string;
gaps: string;
contradictions: string;
unsupported: string;
guidance: string;
}
/**
* Pull the body of a `## …<keyword>…` section out of the Reflector's markdown. Line-scan rather
* than a multi-line regex so it survives emoji headers and trailing whitespace without
* leaning on JS-unsupported regex features.
*/
function extractSection(text: string, headerKeyword: string): string {
if (!text) return '';
const kw = headerKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const headerRe = new RegExp(`^##[^\\n]*${kw}[^\\n]*$`, 'i');
const lines = text.split(/\r?\n/);
const buf: string[] = [];
let inSection = false;
for (const line of lines) {
if (headerRe.test(line)) { inSection = true; continue; }
if (inSection && /^##\s/.test(line)) break;
if (inSection) buf.push(line);
}
return buf.join('\n').trim();
}
function parseReflection(text: string): ReflectionSections {
return {
alignment: extractSection(text, 'Alignment'),
gaps: extractSection(text, 'Gaps'),
contradictions: extractSection(text, 'Contradictions'),
unsupported: extractSection(text, 'Unsupported'),
guidance: extractSection(text, 'Guidance'),
};
}
/**
* "Trivial" = the Reflector explicitly said nothing was found, or the section is too short to be
* meaningful. We don't want to spam the brain with `발견되지 않음` cards.
*/
function isTrivial(section: string): boolean {
if (!section) return true;
const stripped = section.replace(/[-*•·\s\n]+/g, '').toLowerCase();
if (!stripped) return true;
if (/^(없음|발견되지않음|해당없음|na|nothing|none|n\/a)$/.test(stripped)) return true;
if (stripped.length < 12) return true;
return false;
}
function hasSubstantiveContent(sections: ReflectionSections): boolean {
return !isTrivial(sections.gaps)
|| !isTrivial(sections.contradictions)
|| !isTrivial(sections.unsupported)
|| !isTrivial(sections.guidance);
}
/** Pick a short (≤80 char) title from the first actionable bullet across the substantive sections. */
function deriveTitle(sections: ReflectionSections): string {
const order = [sections.guidance, sections.gaps, sections.unsupported, sections.contradictions];
for (const sec of order) {
if (isTrivial(sec)) continue;
const firstBullet = sec.split('\n').find((l) => /^\s*[-*•]/.test(l));
const raw = (firstBullet || sec.split('\n')[0] || '').replace(/^\s*[-*•]\s*/, '').trim();
if (raw.length >= 10) {
return raw.length > 80 ? raw.slice(0, 77) + '…' : raw;
}
}
return 'Reflector finding (auto)';
}
function severityFor(occurrences: number): 'low' | 'medium' | 'high' {
if (occurrences >= 3) return 'high';
if (occurrences >= 2) return 'medium';
return 'low';
}
function buildLessonCard(params: {
title: string;
today: string;
situation: string;
sections: ReflectionSections;
severity: 'low' | 'medium' | 'high';
}): string {
const { title, today, situation, sections, severity } = params;
const safeTitle = title.replace(/\n/g, ' ').trim();
const riskParts = [
isTrivial(sections.gaps) ? '' : `**Gaps**\n${sections.gaps.trim()}`,
isTrivial(sections.unsupported) ? '' : `**Unsupported claims**\n${sections.unsupported.trim()}`,
isTrivial(sections.contradictions) ? '' : `**Contradictions**\n${sections.contradictions.trim()}`,
].filter(Boolean);
const risk = riskParts.length ? riskParts.join('\n\n') : '<critique 본문 비어 있음>';
const rootCause = isTrivial(sections.alignment)
? '<원본 요청 대비 이탈/근본 원인이 critique에 명시되지 않음 — 회고 시 보강>'
: sections.alignment.trim();
const fix = isTrivial(sections.guidance)
? '<Reflector가 Writer 보정 지시(Guidance)를 비워뒀음 — 다음 미션 시 수동 보강 권장>'
: sections.guidance.trim();
const bullets = isTrivial(sections.guidance)
? []
: sections.guidance
.split('\n')
.map((l) => l.trim())
.filter((l) => /^[-*•]/.test(l))
.map((l) => l.replace(/^[-*•]\s*/, ''))
.filter((l) => l.length > 0)
.slice(0, 6);
const checklistBlock = (bullets.length
? bullets
: ['<다음 유사 mission 전에 위 Gaps / Unsupported 항목을 사전 점검>']
).map((c) => `- ${c}`).join('\n');
return [
'---',
'type: lesson',
`title: ${safeTitle}`,
'applies-to: []',
`severity: ${severity}`,
'source: auto-reflector',
'occurrences: 1',
`last-seen: ${today}`,
'---',
'',
`# Lesson: ${safeTitle}`,
'',
'## Situation',
situation,
'',
'## Mistake / Risk',
risk,
'',
'## Root Cause',
rootCause,
'',
'## Fix',
fix,
'',
'## Prevention Checklist',
checklistBlock,
'',
'## Applies To',
'- auto-reflector',
'',
].join('\n');
}
/**
* Cheap title-overlap match against existing auto-reflector cards. Exact-normalized-title hit wins
* outright; otherwise the best ≥60% term-overlap candidate is returned (or none).
*/
function findExistingLesson(autoDir: string, newTitle: string): { filePath: string; content: string } | undefined {
let entries: string[];
try {
entries = fs.readdirSync(autoDir).filter((f) => f.endsWith('.md'));
} catch {
return undefined;
}
const newNorm = normalizeLessonTitle(newTitle);
if (!newNorm) return undefined;
const tokenize = (norm: string) => new Set(norm.match(/[a-z0-9]{3,}|[가-힣]{2,}/g) || []);
const newTerms = tokenize(newNorm);
let best: { filePath: string; content: string; score: number } | undefined;
for (const f of entries) {
const filePath = path.join(autoDir, f);
let content = '';
try {
content = fs.readFileSync(filePath, 'utf8');
} catch {
continue;
}
const fm = parseLessonFrontmatter(content);
const existingTitle = (fm.title || '').trim();
const existingNorm = normalizeLessonTitle(existingTitle);
if (!existingNorm) continue;
if (existingNorm === newNorm) return { filePath, content };
if (newTerms.size === 0) continue;
const existingTerms = tokenize(existingNorm);
let overlap = 0;
for (const t of newTerms) if (existingTerms.has(t)) overlap++;
const score = overlap / newTerms.size;
if (score >= 0.6 && (!best || score > best.score)) {
best = { filePath, content, score };
}
}
return best ? { filePath: best.filePath, content: best.content } : undefined;
}
/**
* Replace (or insert) the `severity:` line in a lesson's frontmatter. Returns content unchanged if
* there is no frontmatter block — caller is responsible for not calling on free-form notes.
*/
function setSeverityInFrontmatter(content: string, severity: 'low' | 'medium' | 'high'): string {
if (!/^?---\s*\n/.test(content)) return content;
const end = content.indexOf('\n---', 4);
if (end < 0) return content;
let block = content.slice(0, end);
const rest = content.slice(end);
if (/^\s*severity\s*:/m.test(block)) {
block = block.replace(/^(\s*severity\s*:\s*).*$/m, `$1${severity}`);
} else {
block += `\nseverity: ${severity}`;
}
return block + rest;
}
export interface PersistResult {
/** Absolute path of the file written or bumped. */
filePath: string;
/** True if an existing lesson was updated (occurrences++); false for a new card. */
bumped: boolean;
/** Current occurrences value after the operation. */
occurrences: number;
/** Current severity after the operation. */
severity: 'low' | 'medium' | 'high';
}
/**
* Persist the Reflector's critique as a lesson card. Returns `undefined` when nothing was written
* (no brain path, critique trivial, IO failure — all soft-fail by design; never throws).
*/
export function persistReflectionAsLesson(params: {
reflection: string;
originalPrompt: string;
brainDir: string;
}): PersistResult | undefined {
const { reflection, originalPrompt, brainDir } = params;
if (!reflection || !brainDir || !path.isAbsolute(brainDir)) return undefined;
try {
const sections = parseReflection(reflection);
if (!hasSubstantiveContent(sections)) {
logInfo('[reflectionPersister] critique is trivial — skipping lesson dump.');
return undefined;
}
const today = new Date().toISOString().slice(0, 10);
const title = deriveTitle(sections);
const autoDir = path.join(brainDir, 'lessons', 'auto-reflector');
try {
fs.mkdirSync(autoDir, { recursive: true });
} catch {
// Fall through; the write below will surface the real failure.
}
const existing = findExistingLesson(autoDir, title);
if (existing) {
let bumped = bumpLessonOccurrences(existing.content, today);
const newOcc = parseLessonFrontmatter(bumped).occurrences ?? 1;
const sev = severityFor(newOcc);
bumped = setSeverityInFrontmatter(bumped, sev);
fs.writeFileSync(existing.filePath, bumped, 'utf8');
logInfo(`[reflectionPersister] bumped existing lesson (occ=${newOcc}, severity=${sev}): ${existing.filePath}`);
return { filePath: existing.filePath, bumped: true, occurrences: newOcc, severity: sev };
}
const situation = (originalPrompt || '').slice(0, 400).replace(/\s+/g, ' ').trim()
|| '<original prompt unavailable>';
const card = buildLessonCard({ title, today, situation, sections, severity: 'low' });
let filePath = path.join(autoDir, `${today}-${lessonSlug(title)}.md`);
let n = 2;
while (fs.existsSync(filePath)) {
filePath = path.join(autoDir, `${today}-${lessonSlug(title)}-${n++}.md`);
}
fs.writeFileSync(filePath, card, 'utf8');
logInfo(`[reflectionPersister] new lesson saved: ${filePath}`);
return { filePath, bumped: false, occurrences: 1, severity: 'low' };
} catch (e: any) {
logError('[reflectionPersister] failed to persist lesson.', { error: e?.message ?? String(e) });
return undefined;
}
}
+3 -14
View File
@@ -135,17 +135,6 @@ export interface IAgentConfig {
* true일 때만 말풍선이 생성된다. 시끄럽게 느껴지면 사용자가 끌 수 있게.
*/
companyPixelOfficeBubbles: boolean;
enableReflection: boolean;
/**
* [Self-Reflection → Knowledge] Reflector critique 중 의미 있는 발견을 brain의
* `lessons/auto-reflector/`에 lesson 카드로 영속화할지 여부. true(기본)이면 동일/유사 패턴이
* 다음 미션에서 retrieval로 자동 주입되고, 같은 critique이 반복될수록 occurrences/severity가
* 누적됨. false면 critique은 그 미션 한정으로만 사용되고 사라짐.
*/
autoLessonFromReflection: boolean;
// ─── 5-stage workflow (Drafter + Synthesizer) ───
/** Drafter(=Writer) 출력 뒤에 SynthesizerAgent로 최종 다듬기 패스를 한 번 더 돌릴지. 기본 true. */
workflowSynthesizerEnabled: boolean;
/**
* Multi-Agent 발동 모드:
* - 'auto' (기본): 작은 모델(≤4B) 감지 OR prompt가 컨텍스트의 큰 비중을 차지할 때만 자동 발동.
@@ -296,15 +285,15 @@ export function getConfig(): IAgentConfig {
const v = (cfg.get<string>('company.intentAlignmentMode', 'smart') || 'smart').trim().toLowerCase();
return v === 'off' || v === 'strict' ? v : 'smart';
})(),
// ALIGNMENT_DEFAULT_MAX_ROUNDS 와 일치 — `intentAlignment` 모듈을 직접 import 하지 않은
// 이유는 config 가 features/ 아래 모듈을 의존하면 의도치 않은 순환 import 가 생기기 때문.
// 둘이 어긋나면 안 되므로 변경 시 양쪽 같이 갱신.
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', false),
selfReflectorExternalEnabled: cfg.get<boolean>('selfReflector.externalVerification', false),
selfReflectorExecutionEnabled: cfg.get<boolean>('selfReflector.executionVerification', false),
companyPixelOfficeEnabled: cfg.get<boolean>('company.pixelOffice.enabled', true),
companyPixelOfficeBubbles: cfg.get<boolean>('company.pixelOffice.bubbles', true),
enableReflection: cfg.get<boolean>('enableReflection', true),
autoLessonFromReflection: cfg.get<boolean>('autoLessonFromReflection', true),
workflowSynthesizerEnabled: cfg.get<boolean>('workflow.synthesizerEnabled', true),
workflowMultiAgentMode: ((): 'auto' | 'always' | 'off' => {
const v = (cfg.get<string>('workflow.multiAgentMode', 'auto') || 'auto').trim().toLowerCase();
return v === 'always' || v === 'off' ? v : 'auto';
+13 -1
View File
@@ -26,6 +26,12 @@ export interface AIChatRequest {
model?: string;
/** Optional override (default = config.timeout). */
timeoutMs?: number;
/**
* 외부 abort signal. fetch 가 받는 signal 과 OR 로 결합되어, 사용자가 회사 모드
* 도중 Stop 을 누르면 진행 중인 generation 이 즉시 중단된다. 없으면 timeout 만
* 적용. dispatcher 같은 긴 multi-turn 경로에서 반드시 전달할 것.
*/
signal?: AbortSignal;
}
export interface AIChatResult {
@@ -95,11 +101,17 @@ export class AIService implements IAIService {
engine, apiUrl, model,
hasSystem: !!req.system, userChars: req.user.length,
});
// timeout signal + 외부 abort signal 결합. 외부 signal 이 fire 되면
// 진행 중인 fetch 가 즉시 중단되어 사용자 Stop 이 LLM generation 중에도 효과.
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const combinedSignal = req.signal
? AbortSignal.any([req.signal, timeoutSignal])
: timeoutSignal;
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
signal: combinedSignal,
});
const rawText = await res.text();
+11
View File
@@ -184,6 +184,17 @@ export async function activate(context: vscode.ExtensionContext) {
})
);
// Datacollect Python 의존성 자동 점검·설치 — `Astra: Setup Datacollect Dependencies`.
// 슬래시 명령(/youtube /research /wikify 등)이 yt-dlp / youtube-transcript-api 같은
// Python 패키지를 필요로 하는데, Astra 확장만 깔면 그게 자동으로 따라오지 않아
// 사용자가 매번 수동 pip install 해야 했음. 이 명령으로 한 번에 처리.
context.subscriptions.push(
vscode.commands.registerCommand('g1nation.setupDatacollect', async () => {
const { runDatacollectSetup } = await import('./features/setup/datacollectSetup');
await runDatacollectSetup();
})
);
// ── Activity Bar launcher view ────────────────────────────────────────
// Adds a sparkle (✦) icon to VS Code's left activity bar. Clicking it
// opens a small sidebar with action buttons (Open Chat / New Chat /
+2 -1
View File
@@ -227,7 +227,7 @@ export async function runCeoPlanner(
ai: IAIService,
userPrompt: string,
state: CompanyState,
options: { model?: string; timeoutMs?: number; contractBlock?: string } = {},
options: { model?: string; timeoutMs?: number; contractBlock?: string; signal?: AbortSignal } = {},
): Promise<PlannerResult> {
const baseSystem = applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName });
// Contract가 있으면 planner 시스템 프롬프트 끝에 prepend. planner는 task
@@ -243,6 +243,7 @@ export async function runCeoPlanner(
user: userPrompt,
model: options.model,
timeoutMs: options.timeoutMs,
signal: options.signal,
});
raw = result.content || '';
} catch (e: any) {
+2 -1
View File
@@ -99,7 +99,7 @@ export async function runCeoReporter(
plan: CompanyTaskPlan,
outputs: AgentTurnOutput[],
state: CompanyState,
options: { model?: string; timeoutMs?: number } = {},
options: { model?: string; timeoutMs?: number; signal?: AbortSignal } = {},
): Promise<ReportResult> {
const system = applyPromptVars(CEO_REPORT_PROMPT, { company: state.companyName });
const user = _buildReportUserMessage(plan, outputs, state);
@@ -109,6 +109,7 @@ export async function runCeoReporter(
user,
model: options.model,
timeoutMs: options.timeoutMs,
signal: options.signal,
});
const text = (result.content || '').trim();
if (!text) {
+8 -2
View File
@@ -337,6 +337,7 @@ export async function runCompanyTurn(
contractBlock: deps.requirementContract
? formatContractForPrompt(deps.requirementContract)
: undefined,
signal: deps.signal,
});
plan = plannerResult.plan;
plannerRaw = plannerResult.raw;
@@ -445,7 +446,7 @@ export async function runCompanyTurn(
plan,
outputs,
state,
{ model: reportModel },
{ model: reportModel, signal: deps.signal },
);
writeReport(sessionDir, reportResult.report);
emit({ phase: 'report-done', report: reportResult.report, ok: reportResult.ok });
@@ -660,6 +661,7 @@ async function _dispatchOne(
system,
user: task,
model,
signal: deps.signal,
});
let rawResponse = (result.content || '').trim();
@@ -697,6 +699,7 @@ async function _dispatchOne(
try {
const retryRes = await deps.ai.chat({
system, user: retryTask, model,
signal: deps.signal,
});
const retried = (retryRes.content || '').trim();
if (retried) {
@@ -777,7 +780,7 @@ async function _dispatchOne(
try {
const { formatIssuesForRetry } = await import('../selfReflector/selfReflectorVerifier');
const retryTask = `${formatIssuesForRetry(verifierIssues)}\n\n[원래 지시]\n${task}`;
const retryRes = await deps.ai.chat({ system, user: retryTask, model });
const retryRes = await deps.ai.chat({ system, user: retryTask, model, signal: deps.signal });
const retried = (retryRes.content || '').trim();
if (retried) {
// 재작업 결과로 본문 갱신 + action-tag 다시 실행.
@@ -937,6 +940,7 @@ async function _resolveStageAgent(
const result = await deps.ai.chat({
system, user,
model: modelForAgent(state, 'ceo', deps.defaultModel),
signal: deps.signal,
});
const raw = (result.content || '').trim();
// 가벼운 파서 — 코드펜스 / 잡문 제거 후 첫 {…} 추출.
@@ -1085,6 +1089,7 @@ async function _runReviewCycle(args: {
system: inspectorSystem,
user: inspectorUser,
model: modelForAgent(state, inspector.agentId, deps.defaultModel),
signal: deps.signal,
});
inspectorText = (res.content || '').trim();
} catch (e: any) {
@@ -1108,6 +1113,7 @@ async function _runReviewCycle(args: {
system: ceoSystem,
user: ceoUser,
model: modelForAgent(state, 'ceo', deps.defaultModel),
signal: deps.signal,
});
ceoText = (res.content || '').trim();
} catch (e: any) {
+9
View File
@@ -19,6 +19,15 @@ import { IAIService } from '../../core/services';
import { logError, logInfo } from '../../utils';
import { RequirementContract } from './types';
/**
* Alignment . config `company.intentAlignmentMaxRounds`
* fallback . config [1,5] clamp.
*
* 의도: 사용자가 * * .
* smart , strict .
*/
export const ALIGNMENT_DEFAULT_MAX_ROUNDS = 3;
/**
* . contract는 ,
* confidence가 medium/low이고 openQuestions가 .
+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);
}
}
+422 -35
View File
@@ -80,8 +80,22 @@ export async function handleSlashCommand(
}
return true;
} catch (e: any) {
logInfo(`[SLASH] handleSlashCommand error head=${head}: ${e?.message || String(e)}`);
chunk(view, `\n\n> ❌ **에러**: ${e?.message || String(e)}\n`);
const errMsg = e?.message || String(e);
logInfo(`[SLASH] handleSlashCommand error head=${head}: ${errMsg}`);
chunk(view, `\n\n> ❌ **에러**: ${errMsg}\n`);
// 자주 발생하는 환경 의존성 에러는 사용자가 즉시 해결할 수 있게 명령 가이드 자동 첨부.
const remedy = _bridgeErrorRemedy(errMsg);
if (remedy) chunk(view, remedy);
// Python 패키지 미설치 패턴이면 한 클릭 설치 notification 도 같이 띄움.
// 채팅 텍스트만 보면 사용자가 명령 팔레트로 가기 귀찮으니까 actionable 버튼 제공.
const pkgMatch = errMsg.match(/필수 패키지가 없습니다?[:\s]+([\w\-,\s.]+)/i)
|| errMsg.match(/missing (?:python )?packages?[:\s]+([\w\-,\s.]+)/i);
if (pkgMatch) {
try {
const { offerInstallNotification } = await import('../setup/datacollectSetup');
void offerInstallNotification(pkgMatch[1].trim());
} catch { /* setup 모듈 로드 실패해도 텍스트 가이드는 이미 보냈으니 무시 */ }
}
return true;
} finally {
// input 잠금 해제 — slashRouter 진입했으면 어떤 경로든 반드시 통과.
@@ -107,45 +121,91 @@ async function runResearch(topic: string, view: Webview | undefined): Promise<bo
chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`);
// Deep research는 보통 1~5분. 5초 polling, 최대 120회(10분).
//
// hang 방어 3겹:
// (1) Bridge status 가 5회 연속 실패하면 polling 포기 — bridge 가 죽은 거.
// (2) heartbeat — 30초마다 진행 상태가 안 바뀌면 "⏳" 한 줄 흘려 사용자가
// "멈춰 있나?" 느끼지 않게.
// (3) status 비교는 트림 + 소문자 — bridge 가 "Completed " 식으로 흘려도 잡힘.
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++;
// status 한 번 호출이 30s를 넘는 사례(stale MCP 자식)가 보고돼 60s로 완화.
const st = await bridgeFetch<{ success: boolean; result: any }>(
`/api/research/status?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`,
{ method: 'GET' },
{ timeoutMs: 60_000 },
);
const status = String(st.result?.status || st.result || '').toLowerCase();
let st: { success: boolean; result: any } | undefined;
try {
st = await bridgeFetch<{ success: boolean; result: any }>(
`/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) {
// 30초간 status 변화 없음 — 사용자에게 살아있다는 신호.
chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`);
lastChangeAt = Date.now();
}
if (status === 'completed' || status === 'done' || status === 'success' || status === 'finished') break;
if (status === 'failed' || status === 'error') {
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`);
// import는 deep research 결과를 노트북 소스로 옮기는 단계. 큰 리포트는 2~5분
// 걸리는 경우가 흔해 120s에서 TRANSIENT_TIMEOUT으로 떨어지는 사례 보고됨. 300s로 늘림.
// heartbeat — 30초마다 진행 표시 흘려 사용자가 "멈췄나?" 의심하지 않게.
await bridgeFetch('/api/research/import', {
method: 'POST',
body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }),
}, { timeoutMs: 300_000 });
}, {
timeoutMs: 300_000,
onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`),
});
chunk(view, `🧪 synthesize…\n\n`);
// synthesize는 LLM이 노트북 전체를 합성 — 큰 노트북은 5~10분. 600s로 cap.
// heartbeat 필수: LLM 단일 호출이 수 분 걸리므로 hang 의심 방지.
const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>(
'/api/research/synthesize',
{
method: 'POST',
body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }),
},
{ timeoutMs: 600_000 },
{
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`);
@@ -694,6 +754,112 @@ function bucketSegments(segments: any[] | undefined, bucketSec = 30): { time: st
}));
}
/** Astra `/youtube` 의 분석 모드. 사용자 입력 `mode:info|benchmark|both`. */
type YoutubeAnalysisMode = 'info' | 'benchmark' | 'both';
/**
* (info) LLM *·* .
*
* 의도: build4LensPrompt "이 영상을 어떻게 베껴 만들지"
* ···· .
* * * ·· ,
* ·· .
*
* build4LensPrompt (, , ).
*/
function buildInfoExtractionPrompt(video: any, userContent: string): string {
const meta = video.metadata || {};
const segments = video.segments || [];
// 자막 본문 — info 모드는 *전체* 본문을 보여줘야 사실 추출이 정확. 단,
// LLM 컨텍스트 한도 고려해 너무 길면 trim. 12000자 = 가벼운 강의 60분 분량 정도.
const fullText = segments.map((s: any) => String(s.text || '').trim()).join(' ').replace(/\s+/g, ' ');
const trimmed = fullText.length > 12000 ? fullText.slice(0, 12000) + ' …[자막 일부 잘림]' : fullText;
const slim = {
url: meta.webpage_url || `https://www.youtube.com/watch?v=${video.video_id}`,
title: meta.title || video.title,
channel: meta.channel,
durationSec: meta.duration,
durationHms: meta.duration_string,
uploadDate: meta.upload_date,
viewCount: meta.view_count,
likeCount: meta.like_count,
tags: (meta.tags || []).slice(0, 8),
categories: meta.categories,
chapters: meta.chapters,
descriptionPreview: (meta.description || '').slice(0, 600),
};
const today = new Date().toISOString().slice(0, 10);
const userBlock = userContent.trim()
? `\n\n[사용자 컨텍스트 — 사용자가 어떤 관점에서 이 영상을 활용하려는지]\n${userContent.trim()}`
: '';
return `당신은 영상 콘텐츠를 *지식 카드*로 변환하는 정보 큐레이터입니다. 사장님이
, * *
(···) .
[ ]
1. () * * . ·· .
2. "본문에 명시되지 않음" . .
3. : \`[근거 명시]\` (구체 출처·수치·인용)·\`[화자 주장]\`
( )·\`[가정]\` (조건부 표현). 모든 핵심 주장에 라벨링.
4. mm:ss . : "…라고 말한다 (12:34)".
5. . ·릿 .
[ ]
\`\`\`json
${JSON.stringify(slim, null, 2)}
\`\`\`
[ ]
${trimmed}${userBlock}
[ . 6 ]
# ${slim.title || video.title}
> ** URL**: ${slim.url} · ** **: ${today} · ****: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · ****: ${slim.channel || '?'}
## 🎯 (TL;DR)
( . "무엇이 누구에게 왜 중요한가" . )
## 📌 3~5
* ·* . + + (mm:ss).
- **[ ]** "주장 한 줄" (mm:ss)
- **[ ]** "주장 한 줄" (mm:ss)
-
## 📊 ··
* ···· *. .
:
| | / | ( ) | |
| --- | --- | --- | --- |
| | | / / | mm:ss |
"본문에 명시된 구체 수치·출처 없음" .
## 🧭 (Sectioned Summary)
chapters () 30 * *. 1~2.
- **[00:0002:30]**
- **[02:3005:00]**
-
## (Open Questions)
2~4.
.
- "본문에서 X 가 Y 라고 했지만 Z 데이터 출처는 명시 안 됨 — 원 데이터 찾아볼 것"
-
## 🔗 (Citation Snippets)
* * . ·· .
3~5. .
- "직접 인용 한 문장" ${slim.title || video.title}, ${slim.channel || '?'} (mm:ss)
- `;
}
/**
* extract된 4-(///CTR) LLM .
* Datacollect (YoutubePanel) build4LensPrompt를 .
@@ -825,26 +991,188 @@ chapters가 있으면 그것을, 없으면 timelinePreview로 구간을 추정.
> · . · / .`;
}
/**
* URL */* . yt-dlp URL
* enumerate , default limit
* ( 1, 10).
*/
function _looksLikeYoutubeChannelUrl(url: string): boolean {
return /youtube\.com\/(channel\/|@|c\/|user\/|playlist\?list=|playlist\/)/i.test(url)
|| /youtube\.com\/[^/?#]+\/(videos|shorts|streams)\b/i.test(url);
}
/**
* URL yt-dlp * * enumerate .
*
* : `https://www.youtube.com/@handle` "루트" yt-dlp
* ID ID(`UC...`) entry
* (Deno-AI ). `/videos` enumerate.
*
* :
* - `/videos`, `/shorts`, `/streams`, `/playlist` path
* - URL (`watch?v=`, `youtu.be/<id>`, `/shorts/<id>`)
* - (`@handle`, `channel/UC..`, `c/name`, `user/name`)
* `/videos` append (query path )
*/
function _normalizeYoutubeUrl(url: string): string {
try {
const u = new URL(url);
if (!/youtube\.com$|youtube\.com\.|youtu\.be$/i.test(u.hostname)) return url;
const p = u.pathname;
// 이미 영상 단위거나 탭/플레이리스트가 명시된 경우는 손대지 말 것.
if (/\/(watch|shorts|playlist|videos|streams|featured|community|about)\b/i.test(p)) return url;
if (u.hostname.includes('youtu.be')) return url; // youtu.be/<id> 는 영상 short link
// 채널 루트 패턴 — `/videos` 를 append (이미 끝 슬래시 있으면 정리).
if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)\/?$/i.test(p)) {
u.pathname = p.replace(/\/?$/, '/videos');
return u.toString();
}
return url;
} catch {
return url; // URL 파싱 실패 시 손대지 않음
}
}
/**
* Datacollect bridge (Python , Python
* ) * * .
* . slashRouter catch
* append .
*/
function _bridgeErrorRemedy(rawMsg: string): string {
const msg = String(rawMsg || '');
// 패턴 1 — Python 패키지 미설치 (bridge 가 명시적으로 알려줌).
const pkgMatch = msg.match(/필수 패키지가 없습니다?[:\s]+([\w\-,\s.]+)/i)
|| msg.match(/missing (?:python )?packages?[:\s]+([\w\-,\s.]+)/i);
if (pkgMatch) {
const pkgs = pkgMatch[1].split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).join(' ');
return `\n\n💡 **해결**: Datacollect bridge 가 도는 환경에서 아래 명령으로 누락된 Python 패키지를 설치하세요.\n\n`
+ '```bash\n'
+ `# macOS (homebrew Python — PEP 668 보호 우회):\n`
+ `python3 -m pip install --user --break-system-packages ${pkgs}\n\n`
+ `# 또는 가상환경(venv) 사용 시 그 venv 활성화 후:\n`
+ `pip install ${pkgs}\n`
+ '```\n\n'
+ `설치 후 **bridge 재시작은 보통 불필요** — bridge 는 Python 을 child process 로 spawn 하므로 다음 호출이 바로 새 패키지를 인식합니다. 그래도 안 되면 \`npm run bridge\` 재시작.\n`;
}
// 패턴 2 — Python 자체가 없거나 PATH 에 없음.
if (/Python 3이 설치돼 있지 않거나 PATH/i.test(msg) || /command not found.*python/i.test(msg)) {
return `\n\n💡 **해결**: Python 3 이 설치돼 있어야 합니다. https://www.python.org 에서 설치 후 터미널에서 \`python3 --version\` 으로 확인하세요. 이미 설치돼 있으면 PATH 설정 확인 필요.`;
}
// 패턴 3 — bridge 자체에 연결 실패.
if (/ECONNREFUSED|fetch failed/i.test(msg) || /연결할 수 없습니다/i.test(msg)) {
return `\n\n💡 **해결**: Datacollect bridge 가 떠 있지 않습니다. \`Datacollector_MAC\` 프로젝트에서 \`npm run bridge\` 실행 후 다시 시도하세요.`;
}
return '';
}
/**
* / cap.
* 1 LLM 30~120s .
*/
const YOUTUBE_BATCH_MAX = 50;
async function runYoutube(arg: string, view: Webview | undefined): Promise<boolean> {
// URL 토큰만 추출, 나머지는 보조 컨텍스트(우리 채널/콘텐츠 설명).
// 토큰 파싱 — URL 뒤로는 두 가지 형태의 키워드 + 자유 컨텍스트 텍스트.
//
// n:<숫자> → 채널일 때 가져올 영상 개수
// mode:<info|benchmark|both> → 분석 모드 (key:value 형)
// info / benchmark / both → 같은 모드의 bare keyword 형 (더 짧고 직관적)
//
// bare keyword 가 작동하는 이유: `info`/`benchmark`/`both` 는 영어 단어이고
// 한국어 사용자가 컨텍스트로 쓸 가능성이 매우 낮아 충돌 위험 적음. 사용자가
// 진짜 이 단어들을 컨텍스트로 넣고 싶으면 `mode:` 접두사를 빼지 말고 명시
// (이 경우 일반 단어도 컨텍스트로 같이 넣을 수 있음).
//
// 위 패턴 중 하나도 매칭 안 되는 토큰은 모두 사용자 컨텍스트로 join.
const BARE_MODE_KEYWORDS = new Set(['info', 'benchmark', 'both']);
const tokens = arg.trim().split(/\s+/).filter(Boolean);
const url = tokens[0] || '';
const userContent = tokens.slice(1).join(' ');
let limitOverride: number | null = null;
let mode: YoutubeAnalysisMode = 'both';
const contextTokens: string[] = [];
for (const tok of tokens.slice(1)) {
const nMatch = tok.match(/^n[:=](\d+)$/i);
if (nMatch) {
const n = parseInt(nMatch[1], 10);
if (Number.isFinite(n) && n > 0) {
limitOverride = Math.min(YOUTUBE_BATCH_MAX, n);
}
continue;
}
const modeMatch = tok.match(/^mode[:=](info|benchmark|both)$/i);
if (modeMatch) {
mode = modeMatch[1].toLowerCase() as YoutubeAnalysisMode;
continue;
}
// Bare keyword 형 — `info` / `benchmark` / `both` 자체를 토큰으로.
const lower = tok.toLowerCase();
if (BARE_MODE_KEYWORDS.has(lower)) {
mode = lower as YoutubeAnalysisMode;
continue;
}
contextTokens.push(tok);
}
const userContent = contextTokens.join(' ');
if (!url) {
chunk(view, `사용법: \`/youtube <url> [우리 채널/콘텐츠 설명]\`\n예: \`/youtube https://youtu.be/xxxx\`\n`);
chunk(view, [
`사용법:\n`,
`- 단일 영상: \`/youtube <영상URL> [info|benchmark|both] [컨텍스트]\`\n`,
`- 채널/플레이리스트: \`/youtube <채널URL> [n:30] [info|benchmark|both] [컨텍스트]\`\n`,
`\n**분석 모드** (생략 시 \`both\`):\n`,
`- \`info\` — 영상의 *내용*을 지식 카드로 추출 (튜토리얼·강의·뉴스·인터뷰)\n`,
`- \`benchmark\` — 대본 역기획서 4-렌즈 분석 (콘텐츠 제작 벤치마크용)\n`,
`- \`both\` — 둘 다 생성 (영상당 LLM 호출 2회)\n`,
`\n예시:\n`,
`- \`/youtube https://youtu.be/abc info\`\n`,
`- \`/youtube https://youtube.com/@somechannel n:20 info AI 학습 자료\`\n`,
`\n💡 \`mode:info\` / \`mode=info\` 같은 명시형도 그대로 동작 (백워드 호환).\n`,
].join(''));
return true;
}
chunk(view, `🎬 **YouTube 추출**: ${url}\n(자막 + 메타데이터)\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`);
// 채널 URL 감지 → 기본 10개. 단일 영상은 1개. 사용자가 `n:N` 으로 명시했으면 그 값.
const isChannel = _looksLikeYoutubeChannelUrl(url);
const limit = limitOverride ?? (isChannel ? 10 : 1);
// yt-dlp 가 영상 목록을 enumerate 할 수 있도록 채널 루트 URL 에 `/videos` 탭을
// 자동 append. 그렇지 않으면 채널 ID(UC...)가 영상 ID 로 잘못 들어가는 사고.
const normalizedUrl = isChannel ? _normalizeYoutubeUrl(url) : url;
if (normalizedUrl !== url) {
chunk(view, `🔧 채널 URL 정규화: \`${url}\`\`${normalizedUrl}\` (yt-dlp 영상 enumeration 을 위한 \`/videos\` 탭 명시)\n\n`);
}
const modeLabel = mode === 'info' ? '📋 정보 추출 (지식 카드)'
: mode === 'benchmark' ? '🎬 벤치마킹 (4-렌즈 역기획서)'
: '📋 정보 추출 + 🎬 벤치마킹 (둘 다)';
if (isChannel) {
const callsPerVideo = mode === 'both' ? 2 : 1;
chunk(view, `📺 **채널/플레이리스트 감지** → 최신 ${limit}개 영상을 1개씩 순차 분석·wiki화 합니다.\n` +
`분석 모드: **${modeLabel}** (영상당 LLM ${callsPerVideo}회 호출)\n` +
`각 영상은 자막추출 → LLM 분석 → wiki 저장 순으로 처리되며, 영상당 보통 30~${120 * callsPerVideo}초.\n` +
`중간에 멈추려면 Astra 사이드바의 ⏹ Stop 을 누르세요.\n\n`);
} else {
chunk(view, `📊 **분석 모드**: ${modeLabel}\n\n`);
}
chunk(view, `🎬 **YouTube 추출**: ${normalizedUrl}\n(자막 + 메타데이터${limit > 1 ? `, ${limit}개 영상` : ''})\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`);
// 1) extract — Bridge는 `source` 필드를 기대한다(`url`이 아님).
const t0 = Date.now();
const heartbeat = setInterval(() => {
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
}, 4000);
// 채널은 영상 수에 비례해 yt-dlp 시간이 늘어남 — limit 비례 timeout 으로 완화.
const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
'/api/youtube/extract',
{ method: 'POST', body: JSON.stringify({ source: url, withMetadata: true, limit: 5 }) },
{ timeoutMs: 5 * 60_000 },
{ method: 'POST', body: JSON.stringify({ source: normalizedUrl, withMetadata: true, limit }) },
{
timeoutMs: extractTimeoutMs,
onHeartbeat: limit > 1
? (elapsedMs) => chunk(view, `\n · 추출 진행 중 (${Math.round(elapsedMs / 1000)}s, ${limit}개 영상)\n`)
: undefined,
},
).finally(() => clearInterval(heartbeat));
const okVideos = (data.videos || []).filter((v: any) => v?.status === 'ok');
@@ -856,39 +1184,86 @@ async function runYoutube(arg: string, view: Webview | undefined): Promise<boole
const cfg = vscode.workspace.getConfiguration('g1nation');
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
const ytSystem = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.';
// 시스템 프롬프트는 모드별로 분리 — info 는 *큐레이터* 톤, benchmark 는 *PD* 톤.
// 작은 모델일수록 system prompt 의 역할 정의가 출력 품질을 크게 좌우.
const sysInfo = '당신은 영상 콘텐츠를 지식 카드로 변환하는 정보 큐레이터입니다. 자막에 명시된 사실만 인용하세요.';
const sysBench = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.';
// 2) 영상마다 LLM 4-렌즈 분석 (보통 1건; 채널/플레이리스트면 순차).
for (const video of okVideos) {
// 영상의 분석을 mode 에 따라 1회 또는 2회 LLM 호출.
// 결과는 (라벨, 보고서 본문) 의 배열로 모아 chat 출력 + wiki 저장에 같은 데이터 사용.
type Section = { label: string; body: string };
async function runOneAnalysis(video: any, prompt: string, system: string, sectionLabel: string, progressTag: string): Promise<Section | null> {
chunk(view, `🧪 **${sectionLabel}**${progressTag} (모델 \`${model}\`)…`);
try {
const t = Date.now();
const body = await callLmSynthesis(prompt, system);
if (!body) throw new Error('LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - t) / 1000)}s)\n\n`);
chunk(view, body + '\n\n');
return { label: sectionLabel, body };
} catch (e: any) {
chunk(view, `\n\n⚠️ ${sectionLabel} 실패${progressTag}: ${e?.message || String(e)}\n`);
return null;
}
}
// 2) 영상마다 LLM 분석 → wiki 저장. **queue 처럼 1개씩 순차** —
// 채널 N개면 i/N 진행 표시. 하나가 실패해도 다음으로 계속 (continue 로
// skip), 다 끝나면 마지막에 통계 요약을 한 줄로 흘림.
const total = okVideos.length;
let analyzedOk = 0;
let analyzedFail = 0;
let savedOk = 0;
let savedFail = 0;
const batchT0 = Date.now();
for (let i = 0; i < okVideos.length; i++) {
const video = okVideos[i];
const vTitle = video?.metadata?.title || video?.title || video?.video_id || '(제목 없음)';
const progressTag = total > 1 ? ` [${i + 1}/${total}]` : '';
if (total > 1) {
chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
}
// 보고서 앞에 영상 전체 스크립트를 먼저 출력 — 분석과 원문 대본을 함께 보도록.
const script = fullScriptFromSegments(video?.segments);
chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`);
chunk(view, `🧪 **LLM 4-렌즈 분석**: ${vTitle} (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
let report: string;
try {
const partT0 = Date.now();
report = await callLmSynthesis(build4LensPrompt(video, userContent), ytSystem);
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)\n\n`);
} catch (e: any) {
chunk(view, `\n\n⚠️ LLM 분석 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
// mode 분기 — info / benchmark / both 에 맞게 0~2회 LLM 호출.
const sections: Section[] = [];
if (mode === 'info' || mode === 'both') {
const sec = await runOneAnalysis(video, buildInfoExtractionPrompt(video, userContent), sysInfo, '📋 정보 추출 (지식 카드)', progressTag);
if (sec) sections.push(sec);
}
if (mode === 'benchmark' || mode === 'both') {
const sec = await runOneAnalysis(video, build4LensPrompt(video, userContent), sysBench, '🎬 벤치마킹 (4-렌즈 역기획서)', progressTag);
if (sec) sections.push(sec);
}
if (sections.length === 0) {
analyzedFail++;
chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
continue;
}
chunk(view, report + '\n\n');
analyzedOk++;
// 3) save — benchmark와 동일하게 /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH).
// wiki 본문은 위에서 LLM 호출한 sections 를 그대로 한 파일에 이어붙여 보관.
try {
const today = new Date().toISOString().slice(0, 10);
const videoUrl = video?.metadata?.webpage_url || `https://www.youtube.com/watch?v=${video?.video_id}`;
const title = `유튜브분석 ${vTitle} ${today}`;
// mode 별로 파일명 접미사 — 같은 영상의 info / benchmark / both 가 한 폴더에서 구분되도록.
const modeSuffix = mode === 'info' ? ' (정보)'
: mode === 'benchmark' ? ' (벤치마크)'
: '';
const title = `유튜브분석 ${vTitle}${modeSuffix} ${today}`;
const sectionDivider = sections.length > 1 ? `\n\n---\n\n` : '';
const fileMarkdown = [
`# ${title}`,
``,
`- **영상 URL**: ${videoUrl}`,
`- **분석 시각**: ${new Date().toISOString()}`,
`- **분석 모드**: ${mode}`,
`- **생성**: Astra /youtube · Datacollect youtube insight`,
``,
`## 📜 전체 스크립트 (Full Script)`,
@@ -897,7 +1272,7 @@ async function runYoutube(arg: string, view: Webview | undefined): Promise<boole
``,
`---`,
``,
report,
sections.map((s) => s.body).join(sectionDivider),
``,
].join('\n');
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
@@ -908,11 +1283,23 @@ async function runYoutube(arg: string, view: Webview | undefined): Promise<boole
{ method: 'POST', body: JSON.stringify(body) },
{ timeoutMs: 30_000 },
);
chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n\n`);
savedOk++;
chunk(view, `💾 **결과물 저장 완료**${progressTag}: \`${saved?.path || '(경로 미확인)'}\`\n\n`);
} catch (e: any) {
chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n\n`);
savedFail++;
chunk(view, `⚠️ 결과물 저장 실패${progressTag}: ${e?.message || String(e)}\n\n`);
}
}
// 배치 처리(=채널/플레이리스트) 끝나면 통계 한 줄로 마무리. 단일 영상은 위에서 이미 끝.
if (total > 1) {
const batchSec = Math.round((Date.now() - batchT0) / 1000);
chunk(view, `\n━━━━━━━━━━━━━━━━━━━━\n`
+ `🏁 **배치 완료** (총 ${batchSec}s · ${total}개 영상)\n`
+ `- 분석: ✅ ${analyzedOk} / ❌ ${analyzedFail}\n`
+ `- 저장: 💾 ${savedOk} / ⚠️ ${savedFail}\n`);
}
return true;
}
+267
View File
@@ -0,0 +1,267 @@
/**
* Datacollect (Python ) / .
*
* 의도: Astra extension `/youtube`, `/research` datacollect
* bridge Python (`yt-dlp`, `youtube-transcript-api`)
* . friction
* VS Code + notification "한 클릭 설치" .
*
* :
* - Python . (bridge
* Datacollector_MAC `npm install + npm run bridge`
* )
* - Python OS python.org .
*/
import * as vscode from 'vscode';
import { spawn } from 'child_process';
import { logInfo, logError } from '../../utils';
/** Datacollect 슬래시 명령들이 의존하는 Python 패키지. */
export const REQUIRED_PY_PACKAGES = ['yt-dlp', 'youtube-transcript-api'] as const;
export interface PythonProbe {
/** Detected python executable (`python3` / `python` / `py`) or null. */
pythonCmd: string | null;
/** Python --version string (or null if not found). */
version: string | null;
/** Importable packages we required. */
installedPackages: Set<string>;
/** Required packages that aren't currently importable. */
missingPackages: string[];
}
/**
* Python + import .
* throw `pythonCmd: null`
* `missingPackages` UI .
*/
export async function probePythonEnv(): Promise<PythonProbe> {
const result: PythonProbe = {
pythonCmd: null,
version: null,
installedPackages: new Set<string>(),
missingPackages: [...REQUIRED_PY_PACKAGES],
};
// Windows 는 `python` / `py` 가 보통 우선, 그 외엔 `python3` 가 안전.
const candidates = process.platform === 'win32'
? ['python', 'py', 'python3']
: ['python3', 'python'];
for (const cmd of candidates) {
const ver = await _capture(cmd, ['--version'], 5_000);
if (ver.exitCode === 0 && /Python\s+3\./i.test(ver.stdout + ver.stderr)) {
result.pythonCmd = cmd;
result.version = (ver.stdout || ver.stderr).trim();
break;
}
}
if (!result.pythonCmd) return result;
// 패키지 import 시도 — 패키지명과 import 명이 다른 케이스(`yt-dlp` → `yt_dlp`)는
// 매핑 테이블로 처리.
const importNameOf: Record<string, string> = {
'yt-dlp': 'yt_dlp',
'youtube-transcript-api': 'youtube_transcript_api',
};
const missing: string[] = [];
for (const pkg of REQUIRED_PY_PACKAGES) {
const importName = importNameOf[pkg] ?? pkg.replace(/-/g, '_');
const probe = await _capture(result.pythonCmd, ['-c', `import ${importName}`], 8_000);
if (probe.exitCode === 0) {
result.installedPackages.add(pkg);
} else {
missing.push(pkg);
}
}
result.missingPackages = missing;
return result;
}
/**
* pip install . macOS homebrew Python (PEP 668) Python
* `--user --break-system-packages` .
* site-packages Python .
*
* VS Code OutputChannel streaming. / boolean .
*/
export async function installMissingPackages(
pythonCmd: string,
packages: string[],
output: vscode.OutputChannel,
): Promise<boolean> {
if (packages.length === 0) return true;
output.appendLine(`\n[Astra Setup] pip install 시작: ${packages.join(', ')}`);
output.appendLine(`[Astra Setup] Python: ${pythonCmd}`);
// 1차 시도: 표준 user-install. 정상 환경에선 충분.
const firstAttempt = await _streamCommand(pythonCmd, ['-m', 'pip', 'install', '--user', ...packages], output, 5 * 60_000);
if (firstAttempt) {
output.appendLine(`[Astra Setup] ✅ 설치 성공 (--user)`);
return true;
}
// PEP 668 (homebrew / debian 등) 보호 환경 자동 폴백. --break-system-packages
// 라는 이름이 무서워 보이지만, `--user` 와 함께 쓰면 user site 로 가서 시스템
// Python 을 건드리지 않는다. 일반 패키지(yt-dlp 등) 설치엔 안전.
output.appendLine(`[Astra Setup] ⚠️ 1차 실패. PEP 668 환경으로 추정 → --break-system-packages 폴백 시도.`);
const secondAttempt = await _streamCommand(
pythonCmd,
['-m', 'pip', 'install', '--user', '--break-system-packages', ...packages],
output,
5 * 60_000,
);
if (secondAttempt) {
output.appendLine(`[Astra Setup] ✅ 설치 성공 (--user --break-system-packages)`);
return true;
}
output.appendLine(`[Astra Setup] ❌ 두 차례 시도 모두 실패. 수동 설치가 필요할 수 있습니다.`);
return false;
}
/**
* VS Code `Astra: Setup Datacollect Dependencies` .
* notification "Install Now"
* . OutputChannel + window message .
*/
export async function runDatacollectSetup(): Promise<void> {
const output = vscode.window.createOutputChannel('Astra Setup');
output.show(true);
output.appendLine('🔧 Astra Datacollect 의존성 점검 시작...');
output.appendLine('');
const probe = await probePythonEnv();
if (!probe.pythonCmd) {
output.appendLine('❌ Python 3 을 찾지 못했습니다.');
output.appendLine(' - macOS: brew install python3 또는 https://www.python.org 에서 설치');
output.appendLine(' - Windows: https://www.python.org 에서 설치 (Add Python to PATH 체크)');
output.appendLine(' - Linux: 패키지 매니저로 설치 (apt install python3 / yum install python3 …)');
vscode.window.showErrorMessage(
'Astra Setup: Python 3 이 PATH 에 없습니다. python.org 에서 설치 후 다시 시도하세요.',
'python.org 열기',
).then((pick) => {
if (pick === 'python.org 열기') {
vscode.env.openExternal(vscode.Uri.parse('https://www.python.org/downloads/'));
}
});
return;
}
output.appendLine(`✅ Python 감지: ${probe.pythonCmd} (${probe.version})`);
output.appendLine(` 설치된 패키지: ${Array.from(probe.installedPackages).join(', ') || '(없음)'}`);
output.appendLine(` 누락된 패키지: ${probe.missingPackages.join(', ') || '(없음)'}`);
output.appendLine('');
if (probe.missingPackages.length === 0) {
output.appendLine('🎉 필수 패키지가 모두 설치돼 있습니다. 아무 작업도 필요 없습니다.');
vscode.window.showInformationMessage('Astra Setup: 모든 Python 의존성이 이미 설치돼 있습니다.');
return;
}
const ok = await installMissingPackages(probe.pythonCmd, probe.missingPackages, output);
if (ok) {
// 설치 후 재검증 — 정말 import 되는지 한 번 더 확인.
const after = await probePythonEnv();
if (after.missingPackages.length === 0) {
output.appendLine('\n✅ 설치 후 import 검증 통과. Datacollect 슬래시 명령을 바로 쓸 수 있습니다.');
vscode.window.showInformationMessage(
`Astra Setup 완료: ${probe.missingPackages.join(', ')} 설치됨. /youtube /research 등 다시 시도해 보세요.`,
);
} else {
output.appendLine(`\n⚠️ pip 은 성공으로 끝났지만 import 검증에서 여전히 ${after.missingPackages.join(', ')} 가 안 보입니다.`);
output.appendLine(' Python 인터프리터가 여러 개 있거나 venv 가 활성화돼 있을 수 있어요. 터미널에서 직접 확인해 보세요:');
output.appendLine(` ${probe.pythonCmd} -c "import yt_dlp; print(yt_dlp.__file__)"`);
vscode.window.showWarningMessage('Astra Setup: 설치는 완료됐지만 import 검증 실패. Output 채널 확인.');
}
} else {
vscode.window.showErrorMessage(
'Astra Setup: pip install 실패. Output 채널의 로그를 확인하세요.',
'Output 열기',
).then((pick) => {
if (pick === 'Output 열기') output.show(true);
});
}
}
/**
* notification.
* slashRouter catch "필수 패키지가 없습니다"
* "Install Now" setup .
*
* Idempotent dismiss .
*/
export async function offerInstallNotification(missingHint: string): Promise<void> {
const pick = await vscode.window.showWarningMessage(
`Astra: Datacollect 의 Python 의존성이 누락돼 있습니다 (${missingHint}). 지금 자동 설치할까요?`,
{ modal: false },
'Install Now',
'나중에',
);
if (pick === 'Install Now') {
await vscode.commands.executeCommand('g1nation.setupDatacollect');
}
}
// ─── Internal helpers ───────────────────────────────────────────────────────
function _capture(cmd: string, args: string[], timeoutMs: number): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
let stdout = '';
let stderr = '';
let done = false;
const finish = (exitCode: number) => {
if (done) return;
done = true;
resolve({ exitCode, stdout, stderr });
};
try {
const proc = spawn(cmd, args, { shell: false, windowsHide: true });
const timer = setTimeout(() => {
try { proc.kill('SIGKILL'); } catch { /* noop */ }
finish(-1);
}, timeoutMs);
proc.stdout?.on('data', (b) => { stdout += b.toString(); });
proc.stderr?.on('data', (b) => { stderr += b.toString(); });
proc.on('error', () => { clearTimeout(timer); finish(-2); });
proc.on('close', (code) => { clearTimeout(timer); finish(code ?? -3); });
} catch {
finish(-4);
}
});
}
function _streamCommand(cmd: string, args: string[], output: vscode.OutputChannel, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
output.appendLine(`$ ${cmd} ${args.join(' ')}`);
let done = false;
const finish = (ok: boolean) => {
if (done) return;
done = true;
resolve(ok);
};
try {
const proc = spawn(cmd, args, { shell: false, windowsHide: true });
const timer = setTimeout(() => {
output.appendLine(`[Astra Setup] ⏱️ ${timeoutMs / 1000}s 초과. 프로세스를 종료합니다.`);
try { proc.kill('SIGKILL'); } catch { /* noop */ }
finish(false);
}, timeoutMs);
proc.stdout?.on('data', (b) => output.append(b.toString()));
proc.stderr?.on('data', (b) => output.append(b.toString()));
proc.on('error', (e) => {
clearTimeout(timer);
logError('[datacollectSetup] spawn error', e);
output.appendLine(`[Astra Setup] spawn 오류: ${e.message}`);
finish(false);
});
proc.on('close', (code) => {
clearTimeout(timer);
logInfo(`[datacollectSetup] ${cmd} exited with code=${code}`);
finish(code === 0);
});
} catch (e: any) {
output.appendLine(`[Astra Setup] 실행 실패: ${e?.message ?? String(e)}`);
finish(false);
}
});
}
+10
View File
@@ -27,6 +27,16 @@ export class AgentDataValidator {
throw new Error(`[IntegrityError] Writer 결과물이 불완전합니다 (${data.length} chars). 최종 보고서 작성 실패 의심.`);
}
break;
case 'outline':
// Outline 응답은 JSON 배열 — 빈 배열 `[]` (single-pass 신호) 도 유효.
// 길이 검증을 우회하고, 형식 검사는 호출자(`parseOutline`) 가 별도로 한다.
break;
case 'direct':
// 짧은 질문에 대한 짧은 답을 허용. 한 문장(≈ 10자) 까지 OK.
if (data.length < 4) {
throw new Error(`[IntegrityError] Direct 답변이 비정상적으로 짧습니다 (${data.length} chars).`);
}
break;
default:
if (data.length < 10) {
throw new Error(`[IntegrityError] ${stage} 에이전트로부터 유효한 응답을 받지 못했습니다.`);
+331 -190
View File
@@ -4,12 +4,10 @@ import * as path from 'path';
import { createHash } from 'crypto';
import { lockManager } from '../core/lock';
import { actionQueue } from '../core/queue';
import { logInfo, logError, getActiveBrainProfile } from '../utils';
import { logInfo, logError } from '../utils';
import { AgentDataValidator, PerformanceProfiler, CognitionAudit } from './diagnostics';
import { WikiFormatter } from './formatter';
import { ErrorType, RecoveryRule } from '../types/interfaces';
import { getConfig } from '../config';
import { persistReflectionAsLesson } from '../agents/reflectionPersister';
export { ErrorType, RecoveryRule };
// ─────────────────────────────────────────────
@@ -49,9 +47,19 @@ export interface IAgent {
// ─────────────────────────────────────────────
/**
*
* .
*
* planner/researcher/reflector/writer/synthesizer 5 persona를 ,
* hop마다 .
* "*답변*을 chunk로 나눠 토큰 압박 회피", stage는
* writer가 3-step (outline section polish) . `section`
* stage는 outline에서 N번 transition된다.
*
* `direct` single-pass outline·section·polish 1
* LLM . outline "쪼갤 필요 없음"
* ( ) .
*/
export type PipelineStage = 'idle' | 'planner' | 'researcher' | 'reflector' | 'writer' | 'synthesizer' | 'completed' | 'error';
export type PipelineStage = 'idle' | 'outline' | 'section' | 'polish' | 'direct' | 'completed' | 'error';
/**
* (Audit) .
@@ -448,19 +456,28 @@ export class CacheManager {
* - Error Recovery Matrix Transient/Permanent
*/
export class AgentEngine {
/** Outline LLM이 제안한 N을 강제로 1..MAX_SECTIONS 로 clamp 한다. */
static readonly MAX_SECTIONS = 5;
/**
* writer agent outline / section / polish
* . options.config.role ChunkedWriter .
*
* IAgent (`_legacyAgents`) .
* planner/researcher .
*/
constructor(
private readonly planner: IAgent,
private readonly researcher: IAgent,
private readonly writer: IAgent,
// [Self-Reflection] Researcher와 Writer 사이에 주입되는 메타인지 노드. 미주입 시 기존 3단계 파이프라인을 그대로 유지.
private readonly reflector?: IAgent,
// [5-stage pipeline] Writer(=Drafter)가 만든 초안을 사용자용 최종 답변으로 다듬는 노드.
// 미주입 시 Writer 출력이 그대로 최종 답변이 된다(기존 동작 유지).
private readonly synthesizer?: IAgent
..._legacyAgents: Array<IAgent | undefined>
) {}
/**
* (Refactored: Atomic State Machine)
* writer chunked .
*
* outline section[1..N] polish
*
* Resilience layer(MissionState · ErrorClassifier · CacheManager)
* , persona별 agent 5 phase .
*/
public async runMission(
missionId: string,
@@ -495,155 +512,121 @@ export class AgentEngine {
return globalCache;
}
// --- Phase 1: Planner ---
const plan = await this.executeStep(
state, 'planner', '전략 수립 중...',
() => this.resilientExecute(state, this.planner, 'Planner', prompt, brainContext, signal, onProgress, {
// --- Fast-path: 명백히 짧은 단일 답변 케이스 ---
// outline LLM 콜 자체를 우회 → 1회 호출로 즉답. 본문 첨부도 없고
// 분석/리서치 키워드도 없고 길이도 짧을 때만 발동. 애매하면 outline 으로
// 위임해서 LLM 이 판정하게 둔다.
if (AgentEngine.isObviouslySimple(prompt)) {
logInfo(`[AgentEngine] fast-path 단일 호출 (prompt ${prompt.length}자)`);
return await this.runSinglePass(
state, prompt, brainContext, signal, onProgress, options,
promptHash, currentModel, 'fast-path',
);
}
// --- Phase 1: Outline ---
// 1번의 LLM 호출로 답변을 몇 개 섹션으로 쪼갤지 결정. JSON 배열 반환.
const outlineRaw = await this.executeStep(
state, 'outline', '답변 구조 잡는 중...',
() => this.resilientExecute(state, this.writer, 'Outline', prompt, brainContext, signal, onProgress, {
...options,
context: brainContext,
signal,
config: { ...options?.config, role: 'planner', isSamePrompt: true }
config: { ...options?.config, role: 'outline' },
}),
prompt, brainContext, signal, onProgress
`outline::${prompt}`, brainContext, signal, onProgress
);
const plannerScore = this.validateResult(plan, 'Planner');
// [Structural Fix] 점수가 낮을수록 더 상세한 근거를 요구(comprehensive)하도록 로직 역전
const researcherLevel: AbstractionLevel = plannerScore < 70 ? 'comprehensive' : 'balanced';
const outline = this.parseOutline(outlineRaw);
const sections = outline.sections;
// --- Phase 2: Researcher ---
const research = await this.executeStep(
state, 'researcher', '핵심 정보 수집 및 분석 중...',
() => this.resilientExecute(state, this.researcher, 'Researcher', plan, brainContext, signal, onProgress, {
// outline 이 빈 배열(`reason === 'empty'`)을 반환했다면 LLM 이
// *명시적으로* "쪼갤 필요 없음" 으로 판정한 것 → section / polish 단계
// 건너뛰고 single-pass 직답. 이미 outline 1회는 썼지만 chunked 전체(2+N회)
// 보단 빠르고, 무엇보다 사용자 의도(짧은 답)에 부합.
//
// `reason === 'fallback'` 은 LLM 응답이 깨져서 우리가 임의로 1-section
// 으로 폴백한 케이스 — 이 경우엔 절대 single-pass 로 가지 말고 chunked
// 본문 1섹션 + polish 로 진행 (옛 버전에선 둘이 구분 안 돼서 우발적 전환).
if (outline.reason === 'empty') {
logInfo(`[AgentEngine] outline 이 명시적으로 단순 답변 판정 → direct 경로 폴백.`);
return await this.runSinglePass(
state, prompt, brainContext, signal, onProgress, options,
promptHash, currentModel, 'outline-fallback',
);
}
const outlineSummary = sections.map((s, i) => `${i + 1}. ${s.heading}${s.scope}`).join('\n');
// --- Phase 2: Sections (N회 반복) ---
// 각 섹션은 *동일* writer + 같은 모델이지만 role='section' 으로 분기.
// 본인 scope 만 다루고 prevSections 를 받아 중복을 피한다.
const sectionTexts: string[] = [];
for (let i = 0; i < sections.length; i++) {
this.checkAbort(signal);
const stageLabel = sections.length === 1
? '본문 작성 중...'
: `섹션 ${i + 1}/${sections.length}: "${sections[i].heading}" 작성 중...`;
// executeStep 의 Resumption 키는 stage 문자열 단일 — section 은 N번 반복하므로
// 캐시키만 idx 로 분리하고 state.results 에는 누적 join 결과를 별도로 저장한다.
const sectionText = await this.runSectionStep(
state, i, sections.length, stageLabel,
async () => this.resilientExecute(state, this.writer, `Section${i + 1}`, '', brainContext, signal, onProgress, {
...options,
context: brainContext,
signal,
config: { ...options?.config, role: 'section', allowFallback: true },
priorResults: {
originalPrompt: prompt,
sectionHeading: sections[i].heading,
sectionScope: sections[i].scope,
outlineSummary,
prevSectionsTrimmed: this.trimPrevSections(sectionTexts, sections),
previousValidData: state.getResult(`section_${i}`),
...options?.priorResults,
},
}),
prompt, brainContext, signal, onProgress
);
sectionTexts.push(sectionText);
}
// 섹션을 합쳐 polish 입력 draft 를 만든다. heading 줄을 같이 박아서
// polish 모델이 구조를 인지할 수 있게.
const joinedDraft = sections
.map((s, i) => `${s.heading}\n${sectionTexts[i] ?? ''}`)
.join('\n\n');
// --- Phase 3: Polish ---
// 1번의 LLM 호출로 오타·할루시네이션·중복 제거 + 첫 문장 결론으로 정렬.
const polishedReport = await this.executeStep(
state, 'polish', '최종 다듬기 중...',
() => this.resilientExecute(state, this.writer, 'Polish', joinedDraft, brainContext, signal, onProgress, {
...options,
context: brainContext,
signal,
config: { ...options?.config, role: 'researcher', isSamePrompt: true },
abstractionLevel: researcherLevel
config: { ...options?.config, role: 'polish', allowFallback: true },
priorResults: {
originalPrompt: prompt,
previousValidData: joinedDraft,
...options?.priorResults,
},
}),
plan, brainContext, signal, onProgress
`polish::${joinedDraft}`, prompt, signal, onProgress
);
// --- Phase 3: Context Preparation (Side Effect of Phase 2) ---
let writerPrep = state.getResult('writerPrep');
if (!writerPrep) {
writerPrep = await this.prepareWriterContext(prompt, plan, brainContext);
state.setResult('writerPrep', writerPrep);
}
const researchScore = this.validateResult(research, 'Researcher');
// [Structural Fix] 점수가 낮을수록 더 상세한 근거를 요구(comprehensive)하도록 로직 역전
const writerLevel: AbstractionLevel = researchScore < 65 ? 'comprehensive' : 'balanced';
// --- Phase 3.5: Reflector (Self-Reflection) ---
// Reflector가 주입되어 있고 옵션에서 명시적으로 끄지 않은 경우에만 실행한다.
// 실패해도 파이프라인을 막지 않는다(soft-fail): Reflector는 품질 보강이지 필수 게이트가 아님.
let reflection = '';
const reflectionDisabled = options?.config?.enableReflection === false;
if (this.reflector && !reflectionDisabled) {
try {
reflection = await this.executeStep(
state, 'reflector', '중간 산출물 자기검증 중...',
() => this.resilientExecute(state, this.reflector!, 'Reflector', research, brainContext, signal, onProgress, {
...options,
context: brainContext,
signal,
config: { ...options?.config, role: 'reflector', isSamePrompt: true },
priorResults: { plan, originalPrompt: prompt, ...options?.priorResults },
abstractionLevel: 'balanced'
}),
// [Cache namespace] Writer와 동일한 (research, prompt) 페어를 쓰면 CacheManager가
// Writer 호출 시 reflector 결과를 그대로 반환해버린다. 단계명을 prefix로 분리.
`reflector::${research}`, prompt, signal, onProgress
);
} catch (reflErr: any) {
// Reflector 실패는 치명적이지 않다. 감사 이력에만 남기고 빈 reflection으로 Writer를 진행시킨다.
if (reflErr?.name === 'AbortError') throw reflErr;
logError(`[AgentEngine] Reflector soft-fail — Writer 계속 진행: ${reflErr?.message || reflErr}`);
reflection = '';
}
// [Self-Reflection → Knowledge] Reflector가 의미 있는 critique을 내놓았으면
// brain에 lesson 카드로 영속화한다. 다음 미션의 Planner/Researcher/Writer는
// 기존 lesson retrieval 경로를 통해 이 카드를 자동으로 inject받는다.
// 동일 패턴 재발 시 카드를 새로 만들지 않고 occurrences를 증가시키며 severity를
// low→medium→high로 가중. fire-and-forget으로 미션 흐름을 막지 않는다.
if (reflection && getConfig().autoLessonFromReflection !== false) {
try {
const brainDir = getActiveBrainProfile()?.localBrainPath;
if (brainDir) {
const result = persistReflectionAsLesson({
reflection,
originalPrompt: prompt,
brainDir,
});
if (result) {
logInfo(`[AgentEngine] Reflector critique → lesson (${result.bumped ? 'bumped' : 'new'}, severity=${result.severity}, occ=${result.occurrences}).`);
}
}
} catch (persistErr: any) {
// Lesson 영속화 실패는 미션 결과에 영향 없음 — 로그만 남기고 계속 진행.
logError(`[AgentEngine] lesson 영속화 실패 (무시): ${persistErr?.message || persistErr}`);
}
}
}
// --- Phase 4: Writer ---
const finalReport = await this.executeStep(
state, 'writer', '최종 리포트 작성 및 편집 중...',
() => this.resilientExecute(state, this.writer, 'Writer', research, prompt, signal, onProgress, {
...options,
context: brainContext,
signal,
config: { role: 'writer', allowFallback: true, isSamePrompt: true, ...options?.config },
priorResults: { plan, writerPrep, reflection, previousValidData: state.getResult('finalReport'), ...options?.priorResults },
abstractionLevel: writerLevel
}),
research, prompt, signal, onProgress
);
state.setResult('finalReport', finalReport);
// --- Phase 4.5: Synthesizer (final polish) ---
// Drafter(=Writer) 출력은 "초안"이다. Synthesizer가 주어졌으면 한 번 더 압축/매끄럽게 정리한다.
// 입력이 작은 draft 뿐이라 컨텍스트가 가벼워, 작은 로컬 모델도 한 번에 처리할 수 있다.
// 실패해도 미션을 막지 않고 Drafter 출력을 그대로 사용한다(soft-fail).
let polishedReport = finalReport;
if (this.synthesizer) {
try {
polishedReport = await this.executeStep(
state, 'synthesizer', '최종 답변 다듬기 중...',
() => this.resilientExecute(state, this.synthesizer!, 'Synthesizer', finalReport, prompt, signal, onProgress, {
...options,
context: brainContext,
signal,
config: { ...options?.config, role: 'synthesizer', isSamePrompt: true },
priorResults: { plan, reflection, originalPrompt: prompt, ...options?.priorResults },
abstractionLevel: 'balanced'
}),
`synthesizer::${finalReport}`, prompt, signal, onProgress
);
if (!polishedReport || polishedReport.trim().length < 24) {
// 합성기가 빈/잘린 결과를 내면 안전하게 초안 사용.
logError('[AgentEngine] Synthesizer returned empty/tiny output — using Drafter output.');
polishedReport = finalReport;
}
} catch (synthErr: any) {
if (synthErr?.name === 'AbortError') throw synthErr;
logError(`[AgentEngine] Synthesizer soft-fail — using Drafter output: ${synthErr?.message || synthErr}`);
polishedReport = finalReport;
}
}
// --- Phase 5: Advice & Standardization ---
const proactiveAdvice = await this.generateProactiveAdvice(polishedReport, prompt, brainContext, signal);
// [Structural Fix] 생성된 제안의 무결성 검증 (최소 길이 50자 이상일 때만 append)
const enrichedReport = proactiveAdvice && proactiveAdvice.length > 50
? `${polishedReport}\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\n${proactiveAdvice}`
// Polish 결과가 비정상적으로 짧으면(빈 응답 등) join 본을 fallback.
const safeReport = (!polishedReport || polishedReport.trim().length < 24)
? joinedDraft
: polishedReport;
const standardizedReport = WikiFormatter.format(enrichedReport, state);
// WikiFormatter는 *지식 아카이브 생성*용 포맷(P-Reinforce v3.0 frontmatter +
// Reliability Audit 표 등)이라 일반 채팅 답변에 강제 적용하면 메타 노이즈만 늘어남.
// 명시적으로 옵션이 켜진 경우(예: datacollect 위키 합성 경로)에만 wrap.
const wantsWikiFormat = options?.config?.formatAsKnowledgeArtifact === true;
const standardizedReport = wantsWikiFormat
? WikiFormatter.format(safeReport, state)
: safeReport;
// 최종 결과 전역 캐싱 (Deduplication - Secure Key with Model Awareness)
CacheManager.set(prompt, `global_final_report_${promptHash}`, standardizedReport, currentModel);
@@ -851,18 +834,209 @@ export class AgentEngine {
}
/**
* Writer가 .
* Researcher와 Phase 3 .
* Fast-path 휴리스틱: prompt "쪼갤 필요 없는 단순 케이스" .
* true false outline LLM .
*
* :
* - < 200
* - ( , , --- )
* - / (////////)
*/
private async prepareWriterContext(prompt: string, plan: string, brainContext: string): Promise<string> {
const contextSummary = [
`[Original Prompt] ${prompt.substring(0, 200)}`,
`[Plan Summary] ${plan.substring(0, 300)}`,
`[Brain Context Available] ${brainContext ? 'Yes' : 'No'} (${brainContext?.length || 0} chars)`
].join('\n');
public static isObviouslySimple(prompt: string): boolean {
if (!prompt) return false;
const trimmed = prompt.trim();
if (trimmed.length === 0) return false;
if (trimmed.length >= 200) return false;
logInfo(`[AgentEngine] [WriterPrep] 초기 컨텍스트 준비 완료 (${contextSummary.length} chars)`);
return contextSummary;
// 본문 첨부 신호: 코드 펜스 / 긴 빈줄 / 마크다운 구분선 / 인용 다수.
const hasAttachment = /```|\n\n\n|^---$|^> .*\n> /m.test(trimmed);
if (hasAttachment) return false;
// 분석/구조화 키워드.
const heavyKeyword = /(분석|리서치|조사|보고서|심층|상세히|꼼꼼히|기획|설계|아키텍처|리뷰|review|analyz|research|deep\s*analysis|strategy|proposal|보고|요약해서\s*정리)/i;
if (heavyKeyword.test(trimmed)) return false;
return true;
}
/**
* Single-pass 경로: outline·section·polish 1 LLM
* . fast-path outline .
*
* stage 'direct' audit trail fast-path / chunked
* .
*/
private async runSinglePass(
state: MissionState,
prompt: string,
brainContext: string,
signal: AbortSignal,
onProgress: (stage: PipelineStage, message: string) => void,
options: AgentExecuteOptions | undefined,
promptHash: string,
currentModel: string,
reason: 'fast-path' | 'outline-fallback',
): Promise<string> {
const stageMessage = reason === 'fast-path'
? '답변 작성 중... (단일 호출 fast-path)'
: '답변 작성 중... (outline 단일 답변 판정)';
const directAnswer = await this.executeStep(
state, 'direct', stageMessage,
() => this.resilientExecute(state, this.writer, 'Direct', prompt, brainContext, signal, onProgress, {
...options,
context: brainContext,
signal,
config: { ...options?.config, role: 'direct', allowFallback: true },
priorResults: {
originalPrompt: prompt,
previousValidData: prompt,
...options?.priorResults,
},
}),
`direct::${prompt}`, brainContext, signal, onProgress,
);
const wantsWikiFormat = options?.config?.formatAsKnowledgeArtifact === true;
const finalReport = wantsWikiFormat
? WikiFormatter.format(directAnswer, state)
: directAnswer;
CacheManager.set(prompt, `global_final_report_${promptHash}`, finalReport, currentModel);
CognitionAudit.auditPolicyCompliance('MissionComplete', finalReport);
this.transition(state, 'completed', '미션 완료', onProgress);
return finalReport;
}
/**
* Outline (JSON ) SectionOutline .
* prose를 3-stage
* tolerant parse: (1) raw, (2) fenced , (3) [..] balanced .
*
* reason :
* - 'empty' LLM `[]` "쪼갤 필요 없음" . direct .
* - 'ok' N>=1 . chunked .
* - 'fallback' JSON . "본문" chunked 1
* ( empty fallback
* parse single-pass ).
*/
private parseOutline(raw: string): {
sections: Array<{ heading: string; scope: string }>;
reason: 'ok' | 'empty' | 'fallback';
} {
const fallbackSections = [{ heading: '본문', scope: '사용자 요청 전체를 다루는 단일 섹션' }];
if (!raw || !raw.trim()) {
return { sections: fallbackSections, reason: 'fallback' };
}
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
const stage1 = (fenced ? fenced[1] : raw).trim();
// null = parse 자체 실패 / 형식 깨짐. [] = LLM 의 명시적 "쪼갤 필요 없음".
// 둘은 의미가 다르므로 호출자 측 분기가 가능하도록 union 으로 반환.
type ParseOk =
| { kind: 'empty' }
| { kind: 'sections'; list: Array<{ heading: string; scope: string }> };
const tryParse = (s: string): ParseOk | null => {
try {
const obj = JSON.parse(s);
if (!Array.isArray(obj)) return null;
if (obj.length === 0) return { kind: 'empty' };
const cleaned = obj
.map((o: any) => ({
heading: typeof o?.heading === 'string' ? o.heading.trim() : '',
scope: typeof o?.scope === 'string' ? o.scope.trim() : '',
}))
.filter((o) => o.heading.length > 0);
if (cleaned.length === 0) return null;
return { kind: 'sections', list: cleaned.slice(0, AgentEngine.MAX_SECTIONS) };
} catch { return null; }
};
const direct = tryParse(stage1);
if (direct) {
return direct.kind === 'empty'
? { sections: [], reason: 'empty' }
: { sections: direct.list, reason: 'ok' };
}
// 첫 [...] balanced 추출
const start = stage1.indexOf('[');
const end = stage1.lastIndexOf(']');
if (start !== -1 && end > start) {
const balanced = tryParse(stage1.slice(start, end + 1));
if (balanced) {
return balanced.kind === 'empty'
? { sections: [], reason: 'empty' }
: { sections: balanced.list, reason: 'ok' };
}
}
logError('[AgentEngine] outline parse 실패 — 단일 본문 섹션 fallback (single-pass 전환 안 함).');
return { sections: fallbackSections, reason: 'fallback' };
}
/**
* polish .
* 300 trim LLM이 "어디까지 적었나" .
* .
*/
private trimPrevSections(
prev: string[],
sections: Array<{ heading: string; scope: string }>,
): string {
if (prev.length === 0) return '';
const TRIM = 300;
return prev
.map((text, i) => {
const heading = sections[i]?.heading ?? `섹션 ${i + 1}`;
const compact = (text || '').replace(/\s+/g, ' ').trim();
const clipped = compact.length > TRIM ? compact.slice(0, TRIM) + '...' : compact;
return `[${heading}] ${clipped}`;
})
.join('\n');
}
/**
* `section` stage 1 . `executeStep` stage 1
* N번 section idx key .
* executeStep (transition · abort · cache · resume · save).
*/
private async runSectionStep(
state: MissionState,
idx: number,
total: number,
progressMessage: string,
action: () => Promise<string>,
cacheKeyPrompt: string,
cacheKeyContext: string,
signal: AbortSignal,
onProgress: (stage: PipelineStage, message: string) => void,
): Promise<string> {
const resumeKey = `section_${idx}`;
const existing = state.getResult(resumeKey);
if (existing) {
logInfo(`[AgentEngine] [Resumption] section ${idx + 1}/${total} 결과가 이미 존재합니다.`);
return existing;
}
this.transition(state, 'section', progressMessage, onProgress);
this.checkAbort(signal);
const cacheKey = `section_${idx}::${cacheKeyPrompt}`;
const cached = CacheManager.get(cacheKey, cacheKeyContext);
let result: string;
if (cached) {
logInfo(`[AgentEngine] [Deduplication] section ${idx + 1}/${total} 캐시 히트.`);
state.resilienceMetrics.deduplications++;
result = cached;
} else {
result = await action();
CacheManager.set(cacheKey, cacheKeyContext, result);
}
state.setResult(resumeKey, result);
return result;
}
private summarizeLog(data: string | undefined, length: number = 100): string {
@@ -871,12 +1045,6 @@ export class AgentEngine {
return clean.length > length ? clean.substring(0, length) + '...' : clean;
}
private validateResult(data: string, step: string): number {
// Error Recovery Matrix: Permanent 오류 발생을 방지하기 위한 선제적 핸드오프 검증
const validation = AgentDataValidator.validateHandoff(step, data);
return validation.score;
}
/**
* [Astra v4.0]
* v4.0 LLM .
@@ -910,31 +1078,4 @@ export class AgentEngine {
return `${context}\n${policyDirectives}`;
}
/**
* [Astra v4.0]
* .
*/
private async generateProactiveAdvice(report: string, originalPrompt: string, context: string, signal: AbortSignal): Promise<string> {
// [Structural Fix] 절단 없는 컨텍스트 전달 (LLM 상상력 제한)
const advicePrompt = `사용자의 원래 요청과 작성된 최종 리포트를 바탕으로,
'전략적 의사결정' '실행 작업' 3 .
, .
요청: ${originalPrompt}
:
${report}`;
try {
// Advisor 전용 설정을 주입하여 역할 혼용 방지
return await this.writer.execute(advicePrompt, context, signal, {
config: {
role: 'advisor',
temperature: 0.1, // 창의성 억제, 사실성 강화
maxTokens: 500
}
});
} catch (err) {
return "다음 단계에 대한 자동 제안을 생성하지 못했습니다. 리포트의 결론 섹션을 참고해 주세요.";
}
}
}
+17 -2
View File
@@ -260,19 +260,28 @@ export class ModelLifecycleManager {
this.cancelLoad();
this.clearIdleTimer();
// ── 1) Unload 이전 모델 (있으면) ──────────────────────────────────────
// 의도: 메모리 회수. 실패해도 load 는 *무조건* 진행 — LM Studio 가 unload
// 못 한 모델은 보통 그냥 그대로 메모리에 떠 있고, load 가 새 모델로 메모리를
// 덮어쓰면서 자연 회수되는 경우가 많다. 여기서 throw 하면 사용자가 모델
// 교체 자체를 못 함.
// 또한 unload 실패해도 currentModel 은 null 로 정리 — 다음 단계에서 어차피
// modelKey 로 덮어쓰지만, 그 사이에 다른 코드가 currentModel 을 읽을 때
// "이미 없는 prev" 를 가리키지 않도록.
if (this.state === 'loaded' && this.currentModel && this.currentModel !== modelKey) {
const prev = this.currentModel;
this.state = 'unloading';
try {
await this.deps.client.unload(prev);
} catch (e: any) {
logError('LM Studio unload before switch failed.', { prev, error: e?.message ?? String(e) });
logError('LM Studio unload before switch failed — load 진행 강행.', { prev, error: e?.message ?? String(e) });
}
this.currentModel = null;
}
this.checkMemoryBudget(modelKey);
// ── 2) Load 새 모델 ───────────────────────────────────────────────────
this.state = 'loading';
this.currentModel = modelKey;
const ac = new AbortController();
@@ -289,10 +298,16 @@ export class ModelLifecycleManager {
void this.deps.client.preloadDraftModel(cfg.draftModel);
}
} catch (e: any) {
if (ac.signal.aborted) return; // superseded — newer switch owns state
if (ac.signal.aborted) {
// 새 switch 가 우리를 abort 시킨 경우 → 그 switch 가 state 를 새로 정함.
// 우리는 손대지 말고 빠진다.
return;
}
logError('LM Studio model load failed.', { model: modelKey, error: e?.message ?? String(e) });
this.deps.notifyError?.(`LM Studio load failed: ${e?.message ?? e}`);
if (this.loadAbort === ac) this.loadAbort = undefined;
// Load 실패 → 어떤 모델도 안 떠 있는 깨끗한 상태로 복귀. 다음 호출이 같은
// 모델을 다시 시도할 수 있게 currentModel 도 비운다.
this.state = 'idle';
this.currentModel = null;
}
+2 -2
View File
@@ -124,8 +124,8 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
.map((c) => {
const metadata = c.metadata;
const conflictTag = metadata.conflictDetected ? ` [⚠️ CONFLICT: ${metadata.conflictSeverity}]` : '';
const densityTag = metadata.informationDensity !== undefined ? ` (Density: ${metadata.informationDensity.toFixed(2)})` : '';
return `- ${c.title}${conflictTag}${densityTag}: ${c.content}`;
const coverageTag = metadata.queryCoverage !== undefined ? ` (Coverage: ${metadata.queryCoverage.toFixed(2)})` : '';
return `- ${c.title}${conflictTag}${coverageTag}: ${c.content}`;
})
.join('\n');
sections.push(`### ${label}\n${items}`);
+1 -1
View File
@@ -297,7 +297,7 @@ export class RetrievalOrchestrator {
// Phase 5: Scoring Intelligence Integration
conflictDetected: s.conflictDetected,
conflictSeverity: s.conflictSeverity,
informationDensity: s.informationDensity,
queryCoverage: s.queryCoverage,
...(isLesson ? { isLesson: true, lessonKind: doc.kind } : {}),
},
});
+11 -6
View File
@@ -205,7 +205,13 @@ export interface ScoredDocument {
matchedTerms: string[];
conflictDetected: boolean;
conflictSeverity: ConflictSeverity;
informationDensity: number;
/**
* Query Coverage = |matchedTermsSet| / |expandedQuery|.
* "이 문서가 쿼리의 몇 % 를 다루고 있는가". `informationDensity`
* * * * *
* . .
*/
queryCoverage: number;
}
/**
@@ -293,9 +299,6 @@ export function scoreTfIdfPreTokenized(
score += tfidf * titleMultiplier;
}
// Information Density: 쿼리 관련 토큰의 밀도 측정
const informationDensity = docTokens.length > 0 ? matchedTerms.length / docTokens.length : 0;
// Recency boost
let recencyBoost = 0;
if (doc.lastModified) {
@@ -316,7 +319,9 @@ export function scoreTfIdfPreTokenized(
const finalScore = (score + recencyBoost + titleBoost) * conflictMultiplier;
// [Structural Fix] Information Density: 쿼리 커버리지 기반으로 계산 방식 정상화
// Query Coverage — 이 문서가 expanded query 의 몇 % 를 cover 했는지.
// 옛날에 `informationDensity` 라는 이름으로 노출됐는데 이름과 계산이 어긋나 있어
// 호출자가 "문서 내 밀도" 로 잘못 해석할 위험이 있었다. 이름·의미 통일.
const queryCoverage = expandedQuery.length > 0
? new Set(matchedTerms).size / expandedQuery.length
: 0;
@@ -329,7 +334,7 @@ export function scoreTfIdfPreTokenized(
matchedTerms: [...new Set(matchedTerms)],
conflictDetected,
conflictSeverity,
informationDensity: queryCoverage // 밀도를 쿼리 커버리지로 대체
queryCoverage,
};
});
}
+1 -1
View File
@@ -36,7 +36,7 @@ export interface RetrievalChunk {
// --- Scoring Intelligence (v2.75.0+) ---
conflictDetected?: boolean;
conflictSeverity?: ConflictSeverity;
informationDensity?: number;
queryCoverage?: number;
// --- Experience Memory ---
/** True when this chunk comes from a lesson / playbook / qa-finding card in the brain. */
+24 -317
View File
@@ -358,329 +358,36 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
return true;
}
// ── 1인 기업 모드 메시지 라우팅 ────────────────────────────────────
// 별도 도메인 핸들러에 위임. 30+ 개의 회사 모드 메시지가 한 파일로 부풀어
// SRP 가 깨졌던 걸 분리. 처리 못 한 경우 false 반환 → 아래 default 로 흐름.
case 'getCompanyStatus':
await provider._sendCompanyStatus();
return true;
case 'getCompanyAgents':
await provider._sendCompanyAgents();
return true;
case 'getCompanyResumable':
await provider._sendCompanyResumable();
return true;
case 'resumeCompanyTurn': {
// 사용자가 "이어서 진행" 칩을 눌렀을 때. timestamp만 받아서 디스크의
// _resume.json을 읽고 그 다음 stage부터 dispatch가 이어진다.
const ts = typeof data.timestamp === 'string' ? data.timestamp : '';
if (!ts) return true;
// userPrompt 인자는 resume 경로에서 무시되지만(plan은 디스크에서 복원)
// 시그니처 일관성을 위해 dummy 값을 전달.
void provider._runCompanyTurn('', ts);
return true;
}
case 'discardResumableSession': {
// 사용자가 명시적으로 재개 항목을 버리고 싶을 때 — resume 파일을 'failed'로
// 마킹해서 listResumable에서 자동 제외. markResumeStatus가 안전한 idempotent
// 작업이라 별도 검증 불필요.
const ts = typeof data.timestamp === 'string' ? data.timestamp : '';
if (!ts) return true;
try {
const { resolveSessionDir } = await import('../features/company');
const { markResumeStatus } = await import('../features/company/resumeStore');
markResumeStatus(resolveSessionDir(provider._context, ts), 'failed', 'discarded-by-user');
} catch { /* 무시 — 다음 푸시에서 자연 복구 */ }
await provider._sendCompanyResumable();
return true;
}
case 'setCompanyEnabled': {
const { setCompanyEnabled } = await import('../features/company');
await setCompanyEnabled(provider._context, !!data.value);
await provider._sendCompanyStatus();
return true;
}
case 'setCompanyName': {
const { setCompanyName } = await import('../features/company');
await setCompanyName(provider._context, typeof data.value === 'string' ? data.value : '');
await provider._sendCompanyStatus();
return true;
}
case 'setCompanyActiveAgents': {
const { setActiveAgents } = await import('../features/company');
const ids = Array.isArray(data.value)
? data.value.filter((v: unknown): v is string => typeof v === 'string')
: [];
await setActiveAgents(provider._context, ids);
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
return true;
}
case 'setCompanyAgentModel': {
const { setAgentModelOverride } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
const model = typeof data.model === 'string' ? data.model : '';
if (agentId) {
await setAgentModelOverride(provider._context, agentId, model);
await provider._sendCompanyAgents();
}
return true;
}
case 'setCompanyAgentDisplay': {
// 이름/역할/이모지/색상 override. 페이로드는 setCompanyAgentPrompt와
// 동일한 패턴 — null이면 전체 reset, 각 필드 빈 문자열이면 그 필드만 reset.
const { setAgentDisplayOverride } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const v = data.override;
const override = v === null
? null
: {
name: typeof v?.name === 'string' ? v.name : undefined,
role: typeof v?.role === 'string' ? v.role : undefined,
emoji: typeof v?.emoji === 'string' ? v.emoji : undefined,
color: typeof v?.color === 'string' ? v.color : undefined,
};
const result = await setAgentDisplayOverride(provider._context, agentId, override);
provider._view?.webview.postMessage({
type: 'setCompanyAgentDisplayResult',
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyAgents();
return true;
}
case 'setCompanyAgentRoleCategory': {
// Override an agent's 직군. Empty / null payload value reverts to
// the def's own roleCategory. CEO is rejected by the backend.
const { setAgentRoleCategory } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const cat = (typeof data.value === 'string' && data.value.trim()) ? data.value.trim() : null;
const result = await setAgentRoleCategory(provider._context, agentId, cat as any);
provider._view?.webview.postMessage({
type: 'setCompanyAgentRoleCategoryResult',
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyAgents();
return true;
}
case 'setCompanyAgentKnowledgeMix': {
// Per-agent Knowledge Mix override. `null`/missing value falls
// back to the global slider. The dispatcher reads this on the
// *next* turn — no restart required.
const { setAgentKnowledgeMix } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const raw = data.value;
const weight = (raw === null || raw === undefined || !Number.isFinite(Number(raw)))
? null
: Math.max(0, Math.min(100, Math.round(Number(raw))));
await setAgentKnowledgeMix(provider._context, agentId, weight);
await provider._sendCompanyAgents();
return true;
}
case 'setCompanyAgentPrompt': {
// Patch one agent's persona / specialty / tagline. Each field is
// optional in the payload; passing an *empty string* explicitly
// clears that field (back to the default from `agents.ts`).
// Sending `null` for the whole override resets every field at once.
const { setAgentPromptOverride } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const v = data.override;
const override = v === null
? null
: {
persona: typeof v?.persona === 'string' ? v.persona : undefined,
specialty: typeof v?.specialty === 'string' ? v.specialty : undefined,
tagline: typeof v?.tagline === 'string' ? v.tagline : undefined,
};
await setAgentPromptOverride(provider._context, agentId, override);
await provider._sendCompanyAgents();
return true;
}
case 'addCompanyAgent': {
// User-defined agent. Payload: { def: CompanyAgentDef }. Returns
// an `addCompanyAgentResult` so the UI overlay can keep its form
// open + show an error when validation fails (id collision etc.).
const { addCustomAgent } = await import('../features/company');
const def = data.def;
const result = await addCustomAgent(provider._context, def ?? {});
provider._view?.webview.postMessage({
type: 'addCompanyAgentResult',
value: result.ok
? { ok: true, agentId: def?.id }
: { ok: false, reason: result.reason },
});
if (result.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
}
return true;
}
case 'deleteCompanyAgent': {
// Delete any agent (built-in via hide, custom via outright removal).
// Backend checks pipeline usage and refuses if any stage references it.
const { removeCompanyAgent } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const result = await removeCompanyAgent(provider._context, agentId);
provider._view?.webview.postMessage({
type: 'deleteCompanyAgentResult',
value: result.ok
? { ok: true, agentId, kind: result.kind }
: { ok: false, agentId, reason: result.reason },
});
if (result.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
}
return true;
}
case 'restoreHiddenAgent': {
// Bring a previously-hidden built-in back into the manage panel.
const { restoreHiddenAgent } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const result = await restoreHiddenAgent(provider._context, agentId);
provider._view?.webview.postMessage({
type: 'restoreHiddenAgentResult',
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
});
if (result.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
}
return true;
}
case 'discardResumableSession':
case 'setCompanyEnabled':
case 'setCompanyName':
case 'setCompanyActiveAgents':
case 'setCompanyAgentModel':
case 'setCompanyAgentDisplay':
case 'setCompanyAgentRoleCategory':
case 'setCompanyAgentKnowledgeMix':
case 'setCompanyAgentPrompt':
case 'addCompanyAgent':
case 'deleteCompanyAgent':
case 'restoreHiddenAgent':
case 'getCompanyPipelines':
await provider._sendCompanyPipelines();
return true;
case 'upsertCompanyPipeline': {
const { upsertPipeline } = await import('../features/company');
const result = await upsertPipeline(provider._context, data.def ?? {});
provider._view?.webview.postMessage({
type: 'upsertCompanyPipelineResult',
value: result.ok ? { ok: true } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'deleteCompanyPipeline': {
const { deletePipeline } = await import('../features/company');
const pid = typeof data.pipelineId === 'string' ? data.pipelineId : '';
if (!pid) return true;
const result = await deletePipeline(provider._context, pid);
provider._view?.webview.postMessage({
type: 'deleteCompanyPipelineResult',
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'getCompanyPipelineTemplate': {
// Returns a template's stages so the editor can pre-fill the form.
const { getPipelineTemplate } = await import('../features/company');
const tplId = typeof data.templateId === 'string' ? data.templateId : '';
const tpl = getPipelineTemplate(tplId);
provider._view?.webview.postMessage({
type: 'companyPipelineTemplateContent',
value: tpl ? {
templateId: tpl.templateId,
suggestedPipelineId: tpl.suggestedPipelineId,
suggestedPipelineName: tpl.suggestedPipelineName,
stages: tpl.stages,
} : null,
});
return true;
}
case 'upsertCompanyPipeline':
case 'deleteCompanyPipeline':
case 'getCompanyPipelineTemplate':
case 'getPixelOfficeState':
// webview가 처음 로드되었거나 사용자가 토글 ON 했을 때 캐시된
// 현재 상태를 다시 받기 위한 요청. read-only.
provider.pixelOfficeResend();
return true;
case 'openPixelOfficePanel':
// 사이드바 mini Pixel Office의 ⛶ 버튼 → editor area에 전체보기 panel 열기.
provider.openPixelOfficePanel();
return true;
case 'respondCompanyAlignment': {
// alignment 카드 버튼: 'proceed' = 현 contract로 dispatch, 'cancel' = 폐기.
const decision = typeof data.decision === 'string' ? data.decision : '';
if (decision === 'proceed') {
await provider._proceedWithCurrentAlignment();
} else if (decision === 'cancel') {
provider.cancelPendingAlignment();
}
return true;
}
case 'respondCompanyApproval': {
// Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제.
// payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? }
const stageId = typeof data.stageId === 'string' ? data.stageId : '';
const decision = typeof data.decision === 'string' ? data.decision : '';
if (!stageId || !['approve', 'revise', 'abort'].includes(decision)) return true;
let payload: any;
if (decision === 'approve') payload = { kind: 'approve' };
else if (decision === 'abort') payload = { kind: 'abort' };
else payload = { kind: 'revise', comment: typeof data.comment === 'string' ? data.comment : '' };
provider.resolveApprovalGate(stageId, payload);
return true;
}
case 'setActiveCompanyPipeline': {
const { setActivePipeline } = await import('../features/company');
const pid = typeof data.pipelineId === 'string' && data.pipelineId.trim()
? data.pipelineId.trim()
: null;
const result = await setActivePipeline(provider._context, pid);
provider._view?.webview.postMessage({
type: 'setActiveCompanyPipelineResult',
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'setCompanyScopePreset': {
// 스코프 프리셋 클릭 — templateId (plan-only / dev-only / full-product-dev) 받아서:
// 1) suggestedPipelineId 의 pipeline 이 state.pipelines 에 없으면 template stamp
// 2) activePipelineId 를 그 id 로 설정
// 이미 stamp 된 pipeline 이라면 stage 사용자 편집을 *유지* (활성화만).
const { getPipelineTemplate, upsertPipeline, setActivePipeline, readCompanyState } =
await import('../features/company');
const tplId = typeof data.templateId === 'string' ? data.templateId : '';
const tpl = getPipelineTemplate(tplId);
if (!tpl) {
provider._view?.webview.postMessage({
type: 'setCompanyScopePresetResult',
value: { ok: false, reason: `알 수 없는 템플릿: ${tplId}` },
});
return true;
}
const state = readCompanyState(provider._context);
if (!state.pipelines || !state.pipelines[tpl.suggestedPipelineId]) {
const stampDef = {
id: tpl.suggestedPipelineId,
name: tpl.suggestedPipelineName,
// stage 는 deep clone — 템플릿 read-only 원본 보호.
stages: tpl.stages.map((s) => ({ ...s })),
};
const stamp = await upsertPipeline(provider._context, stampDef);
if (!stamp.ok) {
provider._view?.webview.postMessage({
type: 'setCompanyScopePresetResult',
value: { ok: false, reason: stamp.reason },
});
return true;
}
}
const activate = await setActivePipeline(provider._context, tpl.suggestedPipelineId);
provider._view?.webview.postMessage({
type: 'setCompanyScopePresetResult',
value: activate.ok
? { ok: true, pipelineId: tpl.suggestedPipelineId, templateId: tplId }
: { ok: false, reason: activate.reason },
});
if (activate.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyPipelines();
}
return true;
case 'respondCompanyAlignment':
case 'respondCompanyApproval':
case 'setActiveCompanyPipeline':
case 'setCompanyScopePreset':
case 'resumeCompanyTurn': {
const { handleCompanyMessage } = await import('./companyHandlers');
return await handleCompanyMessage(provider, data);
}
case 'proactiveTrigger':
await provider._handleProactiveSuggestion(data.context);
+347
View File
@@ -0,0 +1,347 @@
import { SidebarChatProvider } from '../sidebarProvider';
/**
* 1 .
*
* 의도: chatHandlers webview ,
* ~30 (getCompanyStatus / setCompanyAgentDisplay / resume /
* alignment / approval / pipeline / pixelOffice ) single
* file 700+ . chronicleHandlers
* .
*
* true chatHandlers LLM fallback .
* false chatHandlers .
*/
export async function handleCompanyMessage(
provider: SidebarChatProvider,
data: any,
): Promise<boolean> {
switch (data.type) {
case 'getCompanyStatus':
await provider._sendCompanyStatus();
return true;
case 'getCompanyAgents':
await provider._sendCompanyAgents();
return true;
case 'getCompanyResumable':
await provider._sendCompanyResumable();
return true;
case 'resumeCompanyTurn': {
// 사용자가 "이어서 진행" 칩을 눌렀을 때. timestamp만 받아서 디스크의
// _resume.json을 읽고 그 다음 stage부터 dispatch가 이어진다.
const ts = typeof data.timestamp === 'string' ? data.timestamp : '';
if (!ts) return true;
// userPrompt 인자는 resume 경로에서 무시되지만(plan은 디스크에서 복원)
// 시그니처 일관성을 위해 dummy 값을 전달.
void provider._runCompanyTurn('', ts);
return true;
}
case 'discardResumableSession': {
// 사용자가 명시적으로 재개 항목을 버리고 싶을 때 — resume 파일을 'failed'로
// 마킹해서 listResumable에서 자동 제외. markResumeStatus가 안전한 idempotent
// 작업이라 별도 검증 불필요.
const ts = typeof data.timestamp === 'string' ? data.timestamp : '';
if (!ts) return true;
try {
const { resolveSessionDir } = await import('../features/company');
const { markResumeStatus } = await import('../features/company/resumeStore');
markResumeStatus(resolveSessionDir(provider._context, ts), 'failed', 'discarded-by-user');
} catch { /* 무시 — 다음 푸시에서 자연 복구 */ }
await provider._sendCompanyResumable();
return true;
}
case 'setCompanyEnabled': {
const { setCompanyEnabled } = await import('../features/company');
await setCompanyEnabled(provider._context, !!data.value);
await provider._sendCompanyStatus();
return true;
}
case 'setCompanyName': {
const { setCompanyName } = await import('../features/company');
await setCompanyName(provider._context, typeof data.value === 'string' ? data.value : '');
await provider._sendCompanyStatus();
return true;
}
case 'setCompanyActiveAgents': {
const { setActiveAgents } = await import('../features/company');
const ids = Array.isArray(data.value)
? data.value.filter((v: unknown): v is string => typeof v === 'string')
: [];
await setActiveAgents(provider._context, ids);
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
return true;
}
case 'setCompanyAgentModel': {
const { setAgentModelOverride } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
const model = typeof data.model === 'string' ? data.model : '';
if (agentId) {
await setAgentModelOverride(provider._context, agentId, model);
await provider._sendCompanyAgents();
}
return true;
}
case 'setCompanyAgentDisplay': {
// 이름/역할/이모지/색상 override. 페이로드는 setCompanyAgentPrompt와
// 동일한 패턴 — null이면 전체 reset, 각 필드 빈 문자열이면 그 필드만 reset.
const { setAgentDisplayOverride } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const v = data.override;
const override = v === null
? null
: {
name: typeof v?.name === 'string' ? v.name : undefined,
role: typeof v?.role === 'string' ? v.role : undefined,
emoji: typeof v?.emoji === 'string' ? v.emoji : undefined,
color: typeof v?.color === 'string' ? v.color : undefined,
};
const result = await setAgentDisplayOverride(provider._context, agentId, override);
provider._view?.webview.postMessage({
type: 'setCompanyAgentDisplayResult',
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyAgents();
return true;
}
case 'setCompanyAgentRoleCategory': {
// Override an agent's 직군. Empty / null payload value reverts to
// the def's own roleCategory. CEO is rejected by the backend.
const { setAgentRoleCategory } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const cat = (typeof data.value === 'string' && data.value.trim()) ? data.value.trim() : null;
const result = await setAgentRoleCategory(provider._context, agentId, cat as any);
provider._view?.webview.postMessage({
type: 'setCompanyAgentRoleCategoryResult',
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyAgents();
return true;
}
case 'setCompanyAgentKnowledgeMix': {
// Per-agent Knowledge Mix override. `null`/missing value falls
// back to the global slider. The dispatcher reads this on the
// *next* turn — no restart required.
const { setAgentKnowledgeMix } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const raw = data.value;
const weight = (raw === null || raw === undefined || !Number.isFinite(Number(raw)))
? null
: Math.max(0, Math.min(100, Math.round(Number(raw))));
await setAgentKnowledgeMix(provider._context, agentId, weight);
await provider._sendCompanyAgents();
return true;
}
case 'setCompanyAgentPrompt': {
// Patch one agent's persona / specialty / tagline. Each field is
// optional in the payload; passing an *empty string* explicitly
// clears that field (back to the default from `agents.ts`).
// Sending `null` for the whole override resets every field at once.
const { setAgentPromptOverride } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const v = data.override;
const override = v === null
? null
: {
persona: typeof v?.persona === 'string' ? v.persona : undefined,
specialty: typeof v?.specialty === 'string' ? v.specialty : undefined,
tagline: typeof v?.tagline === 'string' ? v.tagline : undefined,
};
await setAgentPromptOverride(provider._context, agentId, override);
await provider._sendCompanyAgents();
return true;
}
case 'addCompanyAgent': {
// User-defined agent. Payload: { def: CompanyAgentDef }. Returns
// an `addCompanyAgentResult` so the UI overlay can keep its form
// open + show an error when validation fails (id collision etc.).
const { addCustomAgent } = await import('../features/company');
const def = data.def;
const result = await addCustomAgent(provider._context, def ?? {});
provider._view?.webview.postMessage({
type: 'addCompanyAgentResult',
value: result.ok
? { ok: true, agentId: def?.id }
: { ok: false, reason: result.reason },
});
if (result.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
}
return true;
}
case 'deleteCompanyAgent': {
// Delete any agent (built-in via hide, custom via outright removal).
// Backend checks pipeline usage and refuses if any stage references it.
const { removeCompanyAgent } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const result = await removeCompanyAgent(provider._context, agentId);
provider._view?.webview.postMessage({
type: 'deleteCompanyAgentResult',
value: result.ok
? { ok: true, agentId, kind: result.kind }
: { ok: false, agentId, reason: result.reason },
});
if (result.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
}
return true;
}
case 'restoreHiddenAgent': {
// Bring a previously-hidden built-in back into the manage panel.
const { restoreHiddenAgent } = await import('../features/company');
const agentId = typeof data.agentId === 'string' ? data.agentId : '';
if (!agentId) return true;
const result = await restoreHiddenAgent(provider._context, agentId);
provider._view?.webview.postMessage({
type: 'restoreHiddenAgentResult',
value: result.ok ? { ok: true, agentId } : { ok: false, reason: result.reason },
});
if (result.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyAgents();
}
return true;
}
case 'getCompanyPipelines':
await provider._sendCompanyPipelines();
return true;
case 'upsertCompanyPipeline': {
const { upsertPipeline } = await import('../features/company');
const result = await upsertPipeline(provider._context, data.def ?? {});
provider._view?.webview.postMessage({
type: 'upsertCompanyPipelineResult',
value: result.ok ? { ok: true } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'deleteCompanyPipeline': {
const { deletePipeline } = await import('../features/company');
const pid = typeof data.pipelineId === 'string' ? data.pipelineId : '';
if (!pid) return true;
const result = await deletePipeline(provider._context, pid);
provider._view?.webview.postMessage({
type: 'deleteCompanyPipelineResult',
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'getCompanyPipelineTemplate': {
// Returns a template's stages so the editor can pre-fill the form.
const { getPipelineTemplate } = await import('../features/company');
const tplId = typeof data.templateId === 'string' ? data.templateId : '';
const tpl = getPipelineTemplate(tplId);
provider._view?.webview.postMessage({
type: 'companyPipelineTemplateContent',
value: tpl ? {
templateId: tpl.templateId,
suggestedPipelineId: tpl.suggestedPipelineId,
suggestedPipelineName: tpl.suggestedPipelineName,
stages: tpl.stages,
} : null,
});
return true;
}
case 'getPixelOfficeState':
// webview가 처음 로드되었거나 사용자가 토글 ON 했을 때 캐시된
// 현재 상태를 다시 받기 위한 요청. read-only.
provider.pixelOfficeResend();
return true;
case 'openPixelOfficePanel':
// 사이드바 mini Pixel Office의 ⛶ 버튼 → editor area에 전체보기 panel 열기.
provider.openPixelOfficePanel();
return true;
case 'respondCompanyAlignment': {
// alignment 카드 버튼: 'proceed' = 현 contract로 dispatch, 'cancel' = 폐기.
const decision = typeof data.decision === 'string' ? data.decision : '';
if (decision === 'proceed') {
await provider._proceedWithCurrentAlignment();
} else if (decision === 'cancel') {
provider.cancelPendingAlignment();
}
return true;
}
case 'respondCompanyApproval': {
// Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제.
// payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? }
const stageId = typeof data.stageId === 'string' ? data.stageId : '';
const decision = typeof data.decision === 'string' ? data.decision : '';
if (!stageId || !['approve', 'revise', 'abort'].includes(decision)) return true;
let payload: any;
if (decision === 'approve') payload = { kind: 'approve' };
else if (decision === 'abort') payload = { kind: 'abort' };
else payload = { kind: 'revise', comment: typeof data.comment === 'string' ? data.comment : '' };
provider.resolveApprovalGate(stageId, payload);
return true;
}
case 'setActiveCompanyPipeline': {
const { setActivePipeline } = await import('../features/company');
const pid = typeof data.pipelineId === 'string' && data.pipelineId.trim()
? data.pipelineId.trim()
: null;
const result = await setActivePipeline(provider._context, pid);
provider._view?.webview.postMessage({
type: 'setActiveCompanyPipelineResult',
value: result.ok ? { ok: true, pipelineId: pid } : { ok: false, reason: result.reason },
});
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'setCompanyScopePreset': {
// 스코프 프리셋 클릭 — templateId (plan-only / dev-only / full-product-dev) 받아서:
// 1) suggestedPipelineId 의 pipeline 이 state.pipelines 에 없으면 template stamp
// 2) activePipelineId 를 그 id 로 설정
// 이미 stamp 된 pipeline 이라면 stage 사용자 편집을 *유지* (활성화만).
const { getPipelineTemplate, upsertPipeline, setActivePipeline, readCompanyState } =
await import('../features/company');
const tplId = typeof data.templateId === 'string' ? data.templateId : '';
const tpl = getPipelineTemplate(tplId);
if (!tpl) {
provider._view?.webview.postMessage({
type: 'setCompanyScopePresetResult',
value: { ok: false, reason: `알 수 없는 템플릿: ${tplId}` },
});
return true;
}
const state = readCompanyState(provider._context);
if (!state.pipelines || !state.pipelines[tpl.suggestedPipelineId]) {
const stampDef = {
id: tpl.suggestedPipelineId,
name: tpl.suggestedPipelineName,
// stage 는 deep clone — 템플릿 read-only 원본 보호.
stages: tpl.stages.map((s) => ({ ...s })),
};
const stamp = await upsertPipeline(provider._context, stampDef);
if (!stamp.ok) {
provider._view?.webview.postMessage({
type: 'setCompanyScopePresetResult',
value: { ok: false, reason: stamp.reason },
});
return true;
}
}
const activate = await setActivePipeline(provider._context, tpl.suggestedPipelineId);
provider._view?.webview.postMessage({
type: 'setCompanyScopePresetResult',
value: activate.ok
? { ok: true, pipelineId: tpl.suggestedPipelineId, templateId: tplId }
: { ok: false, reason: activate.reason },
});
if (activate.ok) {
await provider._sendCompanyStatus();
await provider._sendCompanyPipelines();
}
return true;
}
default:
return false;
}
}
+12 -6
View File
@@ -2288,11 +2288,15 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const mode = opts.mode;
const reachedLimit = opts.roundsAsked >= opts.roundsLimit;
// 자동 진행 조건: smart 모드 + high confidence + open question 없음.
// strict 모드면 절대 자동 진행 안 함 — 항상 사용자 확인.
const canAutoProceed = mode === 'smart'
&& contract.confidence === 'high'
&& contract.openQuestions.length === 0;
// 자동 진행 2가지 조건 (smart 모드 한정 — strict 는 항상 사용자 확인):
// (a) high confidence + openQuestions 0 → 명백히 충분한 alignment
// (b) 라운드 한도 도달 + medium 이상 confidence → 더 물어봐도 별 효용 없으니
// 사용자가 멍하니 확인 클릭만 하게 하지 말고 진행. 무한 round 방어 + UX.
// (b) 케이스에서 confidence='low' 인 경우엔 여전히 confirm 카드로 사용자 결정 요청.
const canAutoProceed = mode === 'smart' && (
(contract.confidence === 'high' && contract.openQuestions.length === 0)
|| (reachedLimit && (contract.confidence === 'high' || contract.confidence === 'medium'))
);
if (canAutoProceed) {
// contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
@@ -2301,6 +2305,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
value: {
kind: 'auto-proceed',
contract,
reachedLimit,
},
});
try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ }
@@ -2345,7 +2350,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
if (!pending) return;
const cfg = getConfig();
const mode = (cfg.companyIntentAlignmentMode === 'strict') ? 'strict' : 'smart';
const roundsLimit = Math.max(1, Math.min(5, cfg.companyIntentAlignmentMaxRounds ?? 3));
const { ALIGNMENT_DEFAULT_MAX_ROUNDS } = await import('./features/company/intentAlignment');
const roundsLimit = Math.max(1, Math.min(5, cfg.companyIntentAlignmentMaxRounds ?? ALIGNMENT_DEFAULT_MAX_ROUNDS));
// 사용자 답변을 미해결 질문들에 일괄 매핑 — 작은 모델이 자연어로 통째로
// 답하기 마련이라 질문별로 분리 안 함. 그냥 "이번 라운드 사용자 추가
// 답변" 한 덩어리로 넣어주면 분석기가 다음 라운드에 그걸 보고 알아서
+240 -625
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -59,14 +59,14 @@ describe('Retrieval Orchestrator Phase 5 Integration Tests', () => {
const denseChunk = result.selectedChunks.find(c => c.title.includes('doc1'));
expect(denseChunk).toBeDefined();
if (denseChunk) {
expect(denseChunk.metadata.informationDensity).toBeGreaterThan(0);
expect(denseChunk.metadata.queryCoverage).toBeGreaterThan(0);
expect(denseChunk.metadata.conflictDetected).toBe(false);
}
// 5. Verify Assembled Context String
const contextString = orchestrator.buildContextString(result);
expect(contextString).toContain('[⚠️ CONFLICT: HIGH]');
expect(contextString).toContain('(Density:');
expect(contextString).toContain('(Coverage:');
});
test('Score Normalization: should normalize scores across brain sources', () => {
+41 -27
View File
@@ -115,59 +115,73 @@ describe('Resilience & Boundary Stress Tests', () => {
}
}, 10000);
test('[Scenario B] 네트워크 블랙아웃 시 Fallback(이전데이터)으로 자동 복구되어야 한다', async () => {
const plannerOutput = 'Plan OK passes validation and meets all length requirements.';
const context = 'Resilience Context';
const fallbackData = 'Emergency Fallback Data from Previous Step';
test('[Scenario B] 섹션 단계 네트워크 블랙아웃 시 Fallback(이전데이터)으로 자동 복구되어야 한다', async () => {
// chunked flow 에서는 outline / section / polish 모두 동일 writer 가 처리.
// section 단계만 transient 실패하도록 role-aware mock 으로 분기하고,
// caller 가 previousValidData 를 주입해 fallback 경로가 활성화되는지 검증.
const fallbackData = 'Emergency Fallback Data from Previous Step. Long enough to satisfy validators.';
const roleAwareMock: IAgent = {
execute: async (_input, _ctx, _signal, options) => {
const role = (options?.config?.role as string | undefined) ?? 'section';
if (role === 'outline') {
return '[{"heading":"본문","scope":"전체 답변"}]';
}
if (role === 'section') {
throw new Error('ECONNREFUSED: Connection refused by peer');
}
// polish
return 'Final Report — polished output that includes recovered data summary.';
},
};
const engine = new AgentEngine(
new MockSuccessAgent(plannerOutput),
new NetworkBlackoutAgent(), // Researcher (여기서 실패 발생)
new MockSuccessAgent('Final Report')
);
// [Intelligent Resilience] priorResults를 통해 이전 데이터를 주입하여 Fallback 유도
const engine = new AgentEngine(roleAwareMock);
const missionId = `stress_fallback_${Date.now()}`;
// fast-path 휴리스틱을 우회해 outline → section → polish 가 모두 돌도록 분석 키워드 포함 prompt 사용.
const chunkedPrompt = '다음 보고서 본문을 종합적으로 분석해서 핵심 사안을 정리하고 향후 개선 방향을 상세히 제안해 주세요. 리뷰는 가능한 한 꼼꼼하게 작성되어야 합니다.';
const result = await engine.runMission(
missionId,
'Prompt',
context,
chunkedPrompt,
'Resilience Context',
new AbortController().signal,
noopProgress,
{ priorResults: { previousValidData: fallbackData }, config: { allowFallback: true } }
);
// 최종 결과물에 Writer의 결과가 포함되어야 함 (Researcher는 fallbackData를 반환했을 것임)
// polish 가 정상 실행돼야 하고, fallback 카운트가 최소 1 (section 실패 → fallback).
expect(result).toContain('Final Report');
const missionPath = path.join(getBaseDir(), '.astra', 'missions', `${missionId}.json`);
const state = JSON.parse(fs.readFileSync(missionPath, 'utf-8'));
expect(state.resilienceMetrics.fallbacks).toBeGreaterThanOrEqual(1);
console.log(` ✅ Fallback Recovery (priorResults) Verified: ${state.resilienceMetrics.fallbacks} instances`);
console.log(` ✅ Fallback Recovery (chunked section step) Verified: ${state.resilienceMetrics.fallbacks} instances`);
}, 15000);
test('[Scenario D] 데이터 충돌 발생 시 Conflict Score가 🚨 High로 기록되어야 한다', async () => {
const validPlan = 'Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.';
const engine = new AgentEngine(
new MockSuccessAgent(validPlan),
{
execute: async () => {
// 명시적 충돌 및 수치 모순 유발
test('[Scenario D] 섹션 응답에 충돌 신호 있을 때 Conflict Score 가 🚨 High 로 기록되어야 한다', async () => {
// 섹션 단계 응답에 [CONFLICT WARNING] + 수치 모순 + 상충 용어를 같이 넣어
// AgentDataValidator.validateHandoff 의 점수가 50 을 넘기는지 확인.
const roleAwareMock: IAgent = {
execute: async (_input, _ctx, _signal, options) => {
const role = (options?.config?.role as string | undefined) ?? 'section';
if (role === 'outline') {
return '[{"heading":"본문","scope":"전체 답변"}]';
}
if (role === 'section') {
return "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.";
}
return 'Final report with inconsistencies. This should be long enough to pass validation.';
},
new MockSuccessAgent('Final report with inconsistencies. This should be long enough to pass validation.')
);
};
const engine = new AgentEngine(roleAwareMock);
const missionId = `stress_conflict_${Date.now()}`;
const result = await engine.runMission(missionId, 'Conflict Test', 'ctx', new AbortController().signal, noopProgress);
const chunkedPrompt = '다음 사항을 종합 분석해서 상충 지점과 충돌 위험을 꼼꼼히 보고하고, 정합성 검증 결과를 상세히 정리해 주세요. 리뷰는 가능한 한 자세하게 작성되어야 합니다.';
await engine.runMission(missionId, chunkedPrompt, 'ctx', new AbortController().signal, noopProgress);
const missionPath = path.join(getBaseDir(), '.astra', 'missions', `${missionId}.json`);
const state = JSON.parse(fs.readFileSync(missionPath, 'utf-8'));
// 수치 모순(25) + 상충 용어(15) + 경고 태그(30) = 70점 예상
// 수치 모순(25) + 상충 용어(15) + 경고 태그(30) = 70 점 예상
expect(state.resilienceMetrics.maxConflictScore).toBeGreaterThan(50);
console.log(` 🚨 High Conflict Detected: Risk Score ${state.resilienceMetrics.maxConflictScore}`);