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:
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||
"createdAt": 1779544958019,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+5
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1779518828393,
|
||||
"createdAt": 1779544964863,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1779544964863,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||
"createdAt": 1779518828392,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1779544964855,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
-5
@@ -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
-1
@@ -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
@@ -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 (0–100): 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"],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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회로 끝내세요.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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가 비어 있지 않다. 호출자가
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:00–02:30]** 도입부에서 다룬 내용 한 문장 요약
|
||||
- **[02:30–05: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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 "다음 단계에 대한 자동 제안을 생성하지 못했습니다. 리포트의 결론 섹션을 참고해 주세요.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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', () => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user