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';
export class AgentWorkflowManager {
/**
* 리팩토링된 고성능 에이전트 엔진을 통해 워크플로우를 실행합니다.
/**
* 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);
}
}
+418 -31
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 }>(
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 },
);
const status = String(st.result?.status || st.result || '').toLowerCase();
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} 에이전트로부터 유효한 응답을 받지 못했습니다.`);
+329 -188
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' },
}),
`outline::${prompt}`, brainContext, signal, onProgress
);
const outline = this.parseOutline(outlineRaw);
const sections = outline.sections;
// 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);
}
const plannerScore = this.validateResult(plan, 'Planner');
// [Structural Fix] 점수가 낮을수록 더 상세한 근거를 요구(comprehensive)하도록 로직 역전
const researcherLevel: AbstractionLevel = plannerScore < 70 ? 'comprehensive' : 'balanced';
// 섹션을 합쳐 polish 입력 draft 를 만든다. heading 줄을 같이 박아서
// polish 모델이 구조를 인지할 수 있게.
const joinedDraft = sections
.map((s, i) => `${s.heading}\n${sectionTexts[i] ?? ''}`)
.join('\n\n');
// --- Phase 2: Researcher ---
const research = await this.executeStep(
state, 'researcher', '핵심 정보 수집 및 분석 중...',
() => this.resilientExecute(state, this.researcher, 'Researcher', plan, brainContext, signal, onProgress, {
// --- 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
}),
plan, brainContext, 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,
config: { ...options?.config, role: 'polish', allowFallback: true },
priorResults: {
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
previousValidData: joinedDraft,
...options?.priorResults,
},
}),
research, prompt, signal, onProgress
`polish::${joinedDraft}`, 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}`);