feat: v2.2.3 - Stability, Self-Reflector & Intent Alignment
- 버전 2.2.3 상향 및 PATCHNOTES.md 업데이트 - [신규] src/features/selfReflector/ - 성찰 실행/검증/프롬프트 모듈 추가 - [신규] intentAlignment.ts, intentClassifier.ts - 의도 정렬 시스템 추가 - [신규] pixelOfficeState.ts - 픽셀 오피스 상태 관리 추가 - sidebarProvider, dispatcher, chatHandlers 핵심 로직 최적화 - astra-2.2.3.vsix 패키지 생성 완료 (298 tests PASS)
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||||
"createdAt": 1778768831416,
|
"createdAt": 1778821460579,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
"createdAt": 1778768831407,
|
"createdAt": 1778821460577,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||||
"createdAt": 1778768831402,
|
"createdAt": 1778821460575,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "---\nid: stress_conflict_1778768831385\ndate: 2026-05-14T14:27:11.421Z\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]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (6ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (10ms)\n",
|
"result": "---\nid: stress_conflict_1778821460557\ndate: 2026-05-15T05:04:20.581Z\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]** 전략 수립 중... (17ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (2ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (3ms)\n",
|
||||||
"createdAt": 1778768831421,
|
"createdAt": 1778821460581,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+11
-11
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"missionId": "stress_conflict_1778768831385",
|
"missionId": "stress_conflict_1778821460557",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"startTime": "2026-05-14T14:27:11.385Z",
|
"startTime": "2026-05-15T05:04:20.557Z",
|
||||||
"totalElapsedMs": 36,
|
"totalElapsedMs": 25,
|
||||||
"results": {
|
"results": {
|
||||||
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||||
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
@@ -16,30 +16,30 @@
|
|||||||
{
|
{
|
||||||
"from": "idle",
|
"from": "idle",
|
||||||
"to": "planner",
|
"to": "planner",
|
||||||
"durationMs": 11,
|
"durationMs": 17,
|
||||||
"message": "전략 수립 중...",
|
"message": "전략 수립 중...",
|
||||||
"ts": "2026-05-14T14:27:11.396Z"
|
"ts": "2026-05-15T05:04:20.574Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "planner",
|
"from": "planner",
|
||||||
"to": "researcher",
|
"to": "researcher",
|
||||||
"durationMs": 6,
|
"durationMs": 2,
|
||||||
"message": "핵심 정보 수집 및 분석 중...",
|
"message": "핵심 정보 수집 및 분석 중...",
|
||||||
"ts": "2026-05-14T14:27:11.402Z"
|
"ts": "2026-05-15T05:04:20.576Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "researcher",
|
"from": "researcher",
|
||||||
"to": "writer",
|
"to": "writer",
|
||||||
"durationMs": 10,
|
"durationMs": 3,
|
||||||
"message": "최종 리포트 작성 및 편집 중...",
|
"message": "최종 리포트 작성 및 편집 중...",
|
||||||
"ts": "2026-05-14T14:27:11.412Z"
|
"ts": "2026-05-15T05:04:20.579Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "writer",
|
"from": "writer",
|
||||||
"to": "completed",
|
"to": "completed",
|
||||||
"durationMs": 9,
|
"durationMs": 2,
|
||||||
"message": "미션 완료",
|
"message": "미션 완료",
|
||||||
"ts": "2026-05-14T14:27:11.421Z"
|
"ts": "2026-05-15T05:04:20.581Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resilienceMetrics": {
|
"resilienceMetrics": {
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
# Astra Patch Notes
|
# Astra Patch Notes
|
||||||
|
|
||||||
|
## v2.2.3 (2026-05-15)
|
||||||
|
### 🚀 Stability & Packaging Refinement
|
||||||
|
- **패키지 정합성 강화:** 최신 코드베이스 변경 사항을 반영하여 빌드 파이프라인을 재검증하고 배포 안정성을 확보했습니다.
|
||||||
|
- **지속적 품질 관리:** 298개 전체 테스트 스위트 통과를 재확인하여 엔진 신뢰성을 유지합니다.
|
||||||
|
- **신규 패키징:** `astra-2.2.3.vsix` 패키지를 통해 최신 안정화 상태를 배포합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## v2.2.2 (2026-05-15)
|
||||||
|
### 🧠 Intelligent Reflection & Knowledge Consolidation
|
||||||
|
- **자가 성찰 결과의 영속성(Reflection Persistence) 강화:** `reflectionPersister.ts`를 통해 에이전트의 비평(Critique)과 성찰 내용을 지식 베이스에 영구적으로 기록하고, 향후 미션에서 이를 자가 학습 데이터로 활용할 수 있는 기반을 마련했습니다.
|
||||||
|
- **작업 재개 로직 안정화:** `resumeStore.ts` 시스템을 최종 정비하여 비즈니스 파이프라인 중단 시의 복구 신뢰도를 극대화했습니다.
|
||||||
|
- **사이드바 제공 로직 최적화:** `sidebarProvider.ts` 대규모 리팩토링을 통해 메시지 핸들링 성능을 개선하고 UI 반응성을 높였습니다.
|
||||||
|
- **아키텍처 문서 및 기획 자산 최신화:** 프로젝트 구조 분석 결과와 기획 레코드를 동기화하여 에이전트의 상황 인지 능력을 향상시켰습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
## v2.2.1 (2026-05-14)
|
## v2.2.1 (2026-05-14)
|
||||||
### 🔄 Autonomous Task Resumption & Engine Resilience
|
### 🔄 Autonomous Task Resumption & Engine Resilience
|
||||||
- **작업 중단 후 자율 재개 기능 도입:** 예기치 않은 오류나 중단 상황에서도 이전에 진행하던 작업 흐름(Company Mission)을 마지막 성공 단계부터 즉시 이어서 실행할 수 있는 `resumeStore` 시스템을 구축했습니다.
|
- **작업 중단 후 자율 재개 기능 도입:** 예기치 않은 오류나 중단 상황에서도 이전에 진행하던 작업 흐름(Company Mission)을 마지막 성공 단계부터 즉시 이어서 실행할 수 있는 `resumeStore` 시스템을 구축했습니다.
|
||||||
|
|||||||
Binary file not shown.
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"projectId": "connectai",
|
"projectId": "connectai",
|
||||||
"projectName": "ConnectAI",
|
"projectName": "connectai",
|
||||||
"projectRoot": "/Volumes/Data/project/Antigravity/ConnectAI",
|
"projectRoot": "E:\\Wiki\\connectai",
|
||||||
"recordRoot": "/Volumes/Data/project/Antigravity/ConnectAI/docs/records/ConnectAI",
|
"recordRoot": "E:\\Wiki\\connectai\\docs\\records\\connectai",
|
||||||
"description": "Auto-created by Project Architecture activation.",
|
"description": "Auto-created by Project Architecture activation.",
|
||||||
"corePurpose": "",
|
"corePurpose": "",
|
||||||
"detailLevel": "standard",
|
"detailLevel": "standard",
|
||||||
"createdAt": "2026-05-13T13:09:33.788Z",
|
"createdAt": "2026-05-14T00:57:32.245Z",
|
||||||
"updatedAt": "2026-05-14T14:28:28.873Z"
|
"updatedAt": "2026-05-15T03:24:10.265Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -919,6 +919,382 @@
|
|||||||
}
|
}
|
||||||
.company-phase-card.approval button:disabled { opacity: 0.55; cursor: default; }
|
.company-phase-card.approval button:disabled { opacity: 0.55; cursor: default; }
|
||||||
|
|
||||||
|
/* 3-way 합의 검수 사이클 카드. review-start이 컨테이너를 만들고
|
||||||
|
review-round 이벤트가 .rev-rounds 안에 라운드 한 줄씩 누적. */
|
||||||
|
.company-phase-card.review {
|
||||||
|
border-color: var(--accent-glow, rgba(99,102,241,0.4));
|
||||||
|
}
|
||||||
|
.company-phase-card.review .rev-rounds {
|
||||||
|
display: flex; flex-direction: column; gap: 8px; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.company-phase-card.review .rev-round {
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(99,102,241,0.04);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.company-phase-card.review .rev-round-head {
|
||||||
|
font-size: 10.5px; color: var(--text-bright); font-weight: 600; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.company-phase-card.review .rev-line {
|
||||||
|
display: flex; gap: 6px; align-items: flex-start; margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.company-phase-card.review .rev-actor {
|
||||||
|
flex-shrink: 0; min-width: 64px;
|
||||||
|
color: var(--text-dim); font-weight: 600;
|
||||||
|
}
|
||||||
|
.company-phase-card.review .rev-body { flex: 1; min-width: 0; }
|
||||||
|
.company-phase-card.review .rev-body p { margin: 0 0 4px 0; }
|
||||||
|
.company-phase-card.review .rev-end {
|
||||||
|
margin-top: 8px; padding: 4px 8px;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
color: var(--text-bright);
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Intent Alignment 카드 — new_task 요청 직후 C-G-C-F 분석 결과를 보여주고
|
||||||
|
질문 / 확인 버튼을 띄움. 다른 phase 카드보다 살짝 무게감을 주려고
|
||||||
|
accent 테두리. */
|
||||||
|
.company-alignment-card {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.company-alignment-card.cancelled {
|
||||||
|
border-color: var(--border);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cph-head {
|
||||||
|
color: var(--text-bright); margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-summary {
|
||||||
|
display: grid; grid-template-columns: auto 1fr; gap: 4px 10px;
|
||||||
|
margin: 6px 0; padding: 6px 8px;
|
||||||
|
background: rgba(99,102,241,0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-key {
|
||||||
|
font-weight: 700; color: var(--text-bright);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-val {
|
||||||
|
color: var(--text-primary); word-break: break-word;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-val em { color: var(--text-dim); font-style: italic; }
|
||||||
|
.company-alignment-card .cal-questions {
|
||||||
|
margin-top: 6px; padding: 6px 8px;
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
background: rgba(99,102,241,0.04);
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-q-head {
|
||||||
|
font-weight: 600; color: var(--text-bright); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-questions ul {
|
||||||
|
margin: 4px 0 4px 16px; padding: 0;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-questions li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-hint {
|
||||||
|
margin-top: 4px; font-size: 10.5px;
|
||||||
|
color: var(--text-dim); font-style: italic;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-conf {
|
||||||
|
margin-top: 6px; font-size: 10.5px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-conf-high { color: #10B981; }
|
||||||
|
.company-alignment-card .cal-conf-medium { color: #F5C518; }
|
||||||
|
.company-alignment-card .cal-conf-low { color: var(--error); }
|
||||||
|
.company-alignment-card .cal-actions {
|
||||||
|
display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-actions button {
|
||||||
|
font-size: 11px; padding: 4px 10px; border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.company-alignment-card .cal-actions button:disabled {
|
||||||
|
opacity: 0.55; cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 고급: 작업 흐름 편집 영역. 기본 접힘 — 일반 사용자는 만질 일 X. */
|
||||||
|
details.pipeline-advanced {
|
||||||
|
border-style: dashed;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
details.pipeline-advanced:not([open]) { padding: 8px 12px; }
|
||||||
|
details.pipeline-advanced summary.pipeline-advanced-summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
user-select: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
details.pipeline-advanced summary::-webkit-details-marker { display: none; }
|
||||||
|
details.pipeline-advanced summary .pa-icon { font-size: 13px; }
|
||||||
|
details.pipeline-advanced summary .pa-title {
|
||||||
|
font-size: 11px; font-weight: 700; color: var(--text-bright);
|
||||||
|
}
|
||||||
|
details.pipeline-advanced summary .pa-hint {
|
||||||
|
font-size: 10px; color: var(--text-dim); flex: 1;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
details.pipeline-advanced[open] summary { margin-bottom: 4px; }
|
||||||
|
details.pipeline-advanced[open] {
|
||||||
|
opacity: 1;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────── Pixel Office ─────────────── */
|
||||||
|
.pixel-office {
|
||||||
|
display: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 0 12px 6px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pixel-office[data-enabled="true"] { display: block; }
|
||||||
|
.pixel-office[data-collapsed="true"] .po-body { display: none; }
|
||||||
|
.po-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(99,102,241,0.08);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.pixel-office[data-collapsed="true"] .po-head { border-bottom: none; }
|
||||||
|
.po-head-left { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.po-title { font-size: 11px; font-weight: 700; color: var(--text-bright); }
|
||||||
|
.po-status-label {
|
||||||
|
font-size: 10px; padding: 2px 8px; border-radius: 9999px;
|
||||||
|
background: var(--bg); color: var(--text-dim);
|
||||||
|
font-weight: 600; letter-spacing: 0.02em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
/* 상태별 색상 강조 — webview JS가 po-status-{status} 클래스를 셋팅. */
|
||||||
|
.po-status-label.po-status-idle { color: var(--text-dim); }
|
||||||
|
.po-status-label.po-status-intake { color: #60A5FA; border-color: #60A5FA; }
|
||||||
|
.po-status-label.po-status-analyzing { color: #A78BFA; border-color: #A78BFA; }
|
||||||
|
.po-status-label.po-status-need_clarification { color: #F5C518; border-color: #F5C518; }
|
||||||
|
.po-status-label.po-status-contract_ready { color: #10B981; border-color: #10B981; }
|
||||||
|
.po-status-label.po-status-planning { color: #22D3EE; border-color: #22D3EE; }
|
||||||
|
.po-status-label.po-status-executing { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.po-status-label.po-status-reviewing { color: #FB923C; border-color: #FB923C; }
|
||||||
|
.po-status-label.po-status-waiting_approval { color: #F472B6; border-color: #F472B6; }
|
||||||
|
.po-status-label.po-status-error { color: var(--error); border-color: var(--error); }
|
||||||
|
.po-status-label.po-status-done { color: #10B981; border-color: #10B981; }
|
||||||
|
|
||||||
|
.po-head-actions { display: flex; gap: 4px; align-items: center; }
|
||||||
|
.po-collapse, .po-expand {
|
||||||
|
background: transparent; border: none; cursor: pointer;
|
||||||
|
color: var(--text-dim); font-size: 12px; line-height: 1;
|
||||||
|
padding: 2px 6px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.po-collapse:hover, .po-expand:hover {
|
||||||
|
color: var(--accent); background: rgba(99,102,241,0.1);
|
||||||
|
}
|
||||||
|
.pixel-office[data-collapsed="true"] .po-collapse { transform: rotate(-90deg); }
|
||||||
|
|
||||||
|
.po-body {
|
||||||
|
display: grid; grid-template-columns: minmax(140px, 180px) 1fr;
|
||||||
|
gap: 8px; padding: 8px 10px;
|
||||||
|
}
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
.po-body { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
/* 좌측 — 픽셀 오피스 씬 (캐릭터 + 책상 + 진행률) */
|
||||||
|
.po-scene {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.po-char-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%; min-height: 90px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: flex-end;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(99,102,241,0.05) 0%, rgba(99,102,241,0.0) 70%);
|
||||||
|
}
|
||||||
|
.po-char {
|
||||||
|
position: relative;
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: transform 0.18s, border-color 0.18s;
|
||||||
|
}
|
||||||
|
.po-char-emoji {
|
||||||
|
font-size: 28px; line-height: 1;
|
||||||
|
}
|
||||||
|
/* 상태에 따른 캐릭터 동작 */
|
||||||
|
.pixel-office[data-status="executing"] .po-char { animation: po-bob 1.6s ease-in-out infinite; }
|
||||||
|
.pixel-office[data-status="analyzing"] .po-char { animation: po-tilt 2.4s ease-in-out infinite; }
|
||||||
|
.pixel-office[data-status="reviewing"] .po-char { animation: po-tilt 1.8s ease-in-out infinite; }
|
||||||
|
.pixel-office[data-status="need_clarification"] .po-char { border-color: #F5C518; }
|
||||||
|
.pixel-office[data-status="waiting_approval"] .po-char { border-color: #F472B6; }
|
||||||
|
.pixel-office[data-status="error"] .po-char {
|
||||||
|
border-color: var(--error);
|
||||||
|
animation: po-shake 0.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.pixel-office[data-status="done"] .po-char { border-color: #10B981; }
|
||||||
|
@keyframes po-bob {
|
||||||
|
0%,100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-3px); }
|
||||||
|
}
|
||||||
|
@keyframes po-tilt {
|
||||||
|
0%,100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(-4deg); }
|
||||||
|
}
|
||||||
|
@keyframes po-shake {
|
||||||
|
0%,100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-2px); }
|
||||||
|
75% { transform: translateX(2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 캐릭터 옆 소품 — 상태에 따라 작은 이모지 (돋보기·체크리스트·코드 등) */
|
||||||
|
.po-char-prop {
|
||||||
|
position: absolute;
|
||||||
|
right: -22px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.po-desk {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
margin-top: -4px;
|
||||||
|
background: linear-gradient(180deg, var(--border) 0%, var(--bg) 100%);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
}
|
||||||
|
.po-progress {
|
||||||
|
width: 100%; height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.po-progress-bar {
|
||||||
|
height: 100%; background: var(--accent);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 말풍선 영역 */
|
||||||
|
.po-bubbles {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%; transform: translateX(-50%);
|
||||||
|
bottom: 56px;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
width: max-content; max-width: 220px;
|
||||||
|
}
|
||||||
|
.po-bubble {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 10.5px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||||
|
position: relative;
|
||||||
|
animation: po-bubble-in 0.25s ease-out;
|
||||||
|
max-width: 220px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.po-bubble::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%; bottom: -4px;
|
||||||
|
transform: translateX(-50%) rotate(45deg);
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.po-bubble.po-bubble-warning { border-color: #F5C518; color: #F5C518; }
|
||||||
|
.po-bubble.po-bubble-warning::after { border-color: #F5C518; }
|
||||||
|
.po-bubble.po-bubble-error { border-color: var(--error); color: var(--error); }
|
||||||
|
.po-bubble.po-bubble-error::after { border-color: var(--error); }
|
||||||
|
.po-bubble.po-bubble-success { border-color: #10B981; color: #10B981; }
|
||||||
|
.po-bubble.po-bubble-success::after { border-color: #10B981; }
|
||||||
|
.po-bubble.po-bubble-fading { animation: po-bubble-out 0.3s ease-in forwards; }
|
||||||
|
@keyframes po-bubble-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px) scale(0.92); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes po-bubble-out {
|
||||||
|
to { opacity: 0; transform: translateY(-4px) scale(0.92); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 우측 — 정보 패널 */
|
||||||
|
.po-panel {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.po-row {
|
||||||
|
display: grid; grid-template-columns: 60px 1fr;
|
||||||
|
gap: 6px; align-items: baseline;
|
||||||
|
}
|
||||||
|
.po-key {
|
||||||
|
color: var(--text-dim); font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.po-val {
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.po-val-status { font-weight: 700; text-transform: lowercase; }
|
||||||
|
.po-section {
|
||||||
|
margin-top: 4px; padding-top: 4px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.po-section-head {
|
||||||
|
font-size: 10px; font-weight: 700; color: var(--text-bright);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.po-need-input { margin: 0 0 0 14px; padding: 0; color: var(--text-primary); }
|
||||||
|
.po-need-input li { margin-bottom: 2px; }
|
||||||
|
.po-approval {
|
||||||
|
color: #F472B6; font-style: italic;
|
||||||
|
}
|
||||||
|
.po-contract {
|
||||||
|
display: grid; grid-template-columns: 40px 1fr;
|
||||||
|
gap: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.po-contract-key { color: var(--text-dim); font-weight: 600; }
|
||||||
|
.po-contract-val { color: var(--text-primary); word-break: break-word; }
|
||||||
|
.po-logs {
|
||||||
|
font-size: 9.5px; line-height: 1.4;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-height: 90px; overflow-y: auto;
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
}
|
||||||
|
.po-logs div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
/* 의도 분류기가 chat/followup 으로 판정했을 때 채팅에 끼우는 작은 라벨.
|
||||||
|
파이프라인 카드는 절대 만들지 않고 한 줄짜리 메타 텍스트만 — 사용자가
|
||||||
|
"왜 파이프라인이 안 돌았지?" 의문을 즉시 해소할 수 있게 한다. */
|
||||||
|
.company-intent-note {
|
||||||
|
font-size: 10.5px; color: var(--text-dim);
|
||||||
|
padding: 4px 8px; margin: 4px 0;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.company-intent-note .cin-label {
|
||||||
|
color: var(--text-bright); font-weight: 600; margin-right: 6px;
|
||||||
|
}
|
||||||
|
.company-intent-note .cin-reason { font-style: italic; }
|
||||||
|
|
||||||
/* Project Architecture chip — three-state surface above the input. */
|
/* Project Architecture chip — three-state surface above the input. */
|
||||||
.arch-chip {
|
.arch-chip {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
+75
-7
@@ -227,11 +227,19 @@
|
|||||||
<div id="addAgentError" class="map-status" style="color:var(--error); margin-top:6px;"></div>
|
<div id="addAgentError" class="map-status" style="color:var(--error); margin-top:6px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Work Pipeline editor. The active pipeline (if any) drives the
|
<!--
|
||||||
dispatcher instead of the CEO planner. Empty list / "기본 (CEO
|
Work Pipeline editor. 기본은 *접혀 있음* — CEO가 사용자 의도를 보고
|
||||||
자유 분배)" → legacy planner behaviour. -->
|
자동으로 적합한 작업 흐름을 선택하므로 일반 사용자는 만질 일이 없다.
|
||||||
<div class="map-section">
|
"고급: 작업 흐름 직접 편집" 토글을 열어야만 보이는 영역으로 옮김.
|
||||||
<div class="map-section-head">
|
기능과 데이터는 그대로 유지 (롤백 안전 · 고급 사용자용).
|
||||||
|
-->
|
||||||
|
<details class="map-section pipeline-advanced">
|
||||||
|
<summary class="pipeline-advanced-summary">
|
||||||
|
<span class="pa-icon">🔧</span>
|
||||||
|
<span class="pa-title">고급: 작업 흐름 직접 편집</span>
|
||||||
|
<span class="pa-hint">평소엔 대표가 자동 분배 — 직접 정의하고 싶을 때만 펼치세요.</span>
|
||||||
|
</summary>
|
||||||
|
<div class="map-section-head" style="margin-top:8px;">
|
||||||
<div>
|
<div>
|
||||||
<div class="map-section-title">작업 흐름</div>
|
<div class="map-section-title">작업 흐름</div>
|
||||||
<div class="map-section-hint">대표에게 맡기거나, 사용자가 정한 순서대로 팀원이 이어서 작업하게 만듭니다.</div>
|
<div class="map-section-hint">대표에게 맡기거나, 사용자가 정한 순서대로 팀원이 이어서 작업하게 만듭니다.</div>
|
||||||
@@ -247,11 +255,11 @@
|
|||||||
<div class="control-row" style="margin-top:8px; gap:8px; align-items:center;">
|
<div class="control-row" style="margin-top:8px; gap:8px; align-items:center;">
|
||||||
<label style="font-size:10px; color:var(--text-dim);">현재 흐름:</label>
|
<label style="font-size:10px; color:var(--text-dim);">현재 흐름:</label>
|
||||||
<select id="activePipelineSel" style="flex:1; padding:4px 8px; background:var(--bg); color:var(--text-primary); border:1px solid var(--border); border-radius:6px; font-size:11px;">
|
<select id="activePipelineSel" style="flex:1; padding:4px 8px; background:var(--bg); color:var(--text-primary); border:1px solid var(--border); border-radius:6px; font-size:11px;">
|
||||||
<option value="">대표가 알아서 분배</option>
|
<option value="">대표가 알아서 분배 (권장)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<ul id="companyPipelineList" class="map-list" style="margin-top:8px;"></ul>
|
<ul id="companyPipelineList" class="map-list" style="margin-top:8px;"></ul>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
<!-- Pipeline editor — 카드형 단계 에디터.
|
<!-- Pipeline editor — 카드형 단계 에디터.
|
||||||
각 단계는 역할 그룹 → 담당 cascading + 지시 텍스트 + 재시도 옵션을
|
각 단계는 역할 그룹 → 담당 cascading + 지시 텍스트 + 재시도 옵션을
|
||||||
@@ -387,6 +395,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Pixel Office — Agent Work Pipeline 상태 시각화 패널.
|
||||||
|
백엔드의 SidebarChatProvider.pixelOfficeOn*() 콜이 push하는 `pixelOfficeUpdate`
|
||||||
|
메시지를 받아 그대로 그린다. dispatcher / agent 로직은 절대 건드리지 않는
|
||||||
|
read-only 시각화 레이어. data-enabled 속성으로 보이기/숨기기 토글.
|
||||||
|
-->
|
||||||
|
<div id="pixelOffice" class="pixel-office" data-enabled="false" data-collapsed="false">
|
||||||
|
<div class="po-head">
|
||||||
|
<div class="po-head-left">
|
||||||
|
<span class="po-title">🏢 Pixel Office</span>
|
||||||
|
<span class="po-status-label" id="poStatusLabel">idle</span>
|
||||||
|
</div>
|
||||||
|
<div class="po-head-actions">
|
||||||
|
<button class="po-expand" id="poExpandBtn" title="전체 사무실 뷰 열기">⛶</button>
|
||||||
|
<button class="po-collapse" id="poCollapseBtn" title="접기/펼치기">▾</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="po-body" id="poBody">
|
||||||
|
<div class="po-scene">
|
||||||
|
<!-- 캐릭터 영역: 단일 캐릭터 + 머리 위 말풍선 슬롯. -->
|
||||||
|
<div class="po-char-wrap">
|
||||||
|
<div class="po-bubbles" id="poBubbles"></div>
|
||||||
|
<div class="po-char" id="poChar">
|
||||||
|
<div class="po-char-emoji" id="poCharEmoji">🧑💼</div>
|
||||||
|
<div class="po-char-prop" id="poCharProp"></div>
|
||||||
|
</div>
|
||||||
|
<div class="po-desk"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 진행률 막대. -->
|
||||||
|
<div class="po-progress" id="poProgress">
|
||||||
|
<div class="po-progress-bar" id="poProgressBar" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="po-panel" id="poPanel">
|
||||||
|
<div class="po-row"><span class="po-key">Agent</span><span class="po-val" id="poAgentName">—</span></div>
|
||||||
|
<div class="po-row"><span class="po-key">Status</span><span class="po-val po-val-status" id="poStatusVal">idle</span></div>
|
||||||
|
<div class="po-row"><span class="po-key">Task</span><span class="po-val" id="poTask">—</span></div>
|
||||||
|
<div class="po-row"><span class="po-key">Step</span><span class="po-val" id="poStep">—</span></div>
|
||||||
|
<div class="po-row" id="poNextStepRow" style="display:none;"><span class="po-key">Next</span><span class="po-val" id="poNextStep">—</span></div>
|
||||||
|
<div class="po-row" id="poMessageRow" style="display:none;"><span class="po-key">Note</span><span class="po-val" id="poMessage">—</span></div>
|
||||||
|
<div class="po-section" id="poNeedInputSection" style="display:none;">
|
||||||
|
<div class="po-section-head">Need User Input</div>
|
||||||
|
<ol class="po-need-input" id="poNeedInputList"></ol>
|
||||||
|
</div>
|
||||||
|
<div class="po-section" id="poApprovalSection" style="display:none;">
|
||||||
|
<div class="po-section-head">Awaiting Approval</div>
|
||||||
|
<div class="po-approval" id="poApprovalText">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="po-section" id="poContractSection" style="display:none;">
|
||||||
|
<div class="po-section-head">Requirement Contract</div>
|
||||||
|
<div class="po-contract" id="poContract"></div>
|
||||||
|
</div>
|
||||||
|
<div class="po-section" id="poLogsSection" style="display:none;">
|
||||||
|
<div class="po-section-head">Recent Logs</div>
|
||||||
|
<div class="po-logs" id="poLogs"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="chat" id="chat">
|
<div class="chat" id="chat">
|
||||||
<!-- Dynamic welcome panel — JS의 _renderWelcome()이 두뇌/모델 상태에 맞춰
|
<!-- Dynamic welcome panel — JS의 _renderWelcome()이 두뇌/모델 상태에 맞춰
|
||||||
시작 체크리스트 또는 예시 질문을 채워 넣음. 첫 메시지가 가면 사라짐. -->
|
시작 체크리스트 또는 예시 질문을 채워 넣음. 첫 메시지가 가면 사라짐. -->
|
||||||
|
|||||||
+485
-12
@@ -947,6 +947,142 @@
|
|||||||
renderCompanyChip(!!v.enabled, v.summary || '');
|
renderCompanyChip(!!v.enabled, v.summary || '');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'companyIntentDecision': {
|
||||||
|
// 1인 기업 모드에서 의도 분류기가 "이건 새 업무가 아님"으로
|
||||||
|
// 판정했을 때 화면에 작은 라벨 한 줄을 띄워, 사용자가 왜
|
||||||
|
// 파이프라인이 안 돌았는지 알 수 있게 한다. new_task로 갈
|
||||||
|
// 땐 라벨 없이 그냥 파이프라인 카드들이 떠서 명확하므로
|
||||||
|
// 별도 알림 필요 없다.
|
||||||
|
const v = msg.value || {};
|
||||||
|
const chatEl = document.getElementById('chat');
|
||||||
|
if (!chatEl) break;
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.className = 'company-intent-note';
|
||||||
|
note.innerHTML = `<span class="cin-label">${escAttr(v.label || '💬 대화')}</span>` +
|
||||||
|
(v.reason ? ` <span class="cin-reason">${escAttr(v.reason)}</span>` : '');
|
||||||
|
chatEl.appendChild(note);
|
||||||
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'pixelOfficeUpdate': {
|
||||||
|
if (typeof window.__pixelOfficeApply === 'function') {
|
||||||
|
window.__pixelOfficeApply(msg.value || {});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'companyAlignmentCard': {
|
||||||
|
// Intent Alignment 카드. kind에 따라 4가지 모드:
|
||||||
|
// - 'auto-proceed' : confidence high → 자동 진행 안내(읽기 전용)
|
||||||
|
// - 'questions' : 답해야 할 질문 + 채팅으로 답변 안내
|
||||||
|
// - 'confirm' : 질문 없음, ✅진행 / 🛑취소 버튼
|
||||||
|
// - 'cancelled' : 사용자가 취소 — 카드 한 줄로 종료 표시
|
||||||
|
const v = msg.value || {};
|
||||||
|
const chatEl = document.getElementById('chat');
|
||||||
|
if (!chatEl) break;
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'company-alignment-card';
|
||||||
|
if (v.kind === 'cancelled') {
|
||||||
|
card.classList.add('cancelled');
|
||||||
|
card.innerHTML = '<div class="cph-meta">🛑 의도 정렬 취소됨 — 작업이 시작되지 않았습니다</div>';
|
||||||
|
chatEl.appendChild(card);
|
||||||
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const c = v.contract || {};
|
||||||
|
const kindLabel = v.kind === 'auto-proceed' ? '✅ 의도 분석 완료 — 곧장 진행'
|
||||||
|
: v.kind === 'confirm' ? '🧭 요청 정리 — 확인 후 진행'
|
||||||
|
: '🤔 추가 정보 필요';
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'cph-head';
|
||||||
|
head.innerHTML = `<strong>${escAttr(kindLabel)}</strong>`;
|
||||||
|
if (typeof v.roundsAsked === 'number' && typeof v.roundsLimit === 'number') {
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'cph-meta';
|
||||||
|
meta.style.marginLeft = '8px';
|
||||||
|
meta.textContent = `라운드 ${v.roundsAsked + 1}/${v.roundsLimit}`;
|
||||||
|
head.appendChild(meta);
|
||||||
|
}
|
||||||
|
card.appendChild(head);
|
||||||
|
|
||||||
|
// ── C-G-C-F summary block ──
|
||||||
|
const summary = document.createElement('div');
|
||||||
|
summary.className = 'cal-summary';
|
||||||
|
const dl = (label, val) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'cal-row';
|
||||||
|
row.innerHTML = `<span class="cal-key">${escAttr(label)}</span><span class="cal-val">${val ? fmt(val) : '<em>(미정)</em>'}</span>`;
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
summary.appendChild(dl('맥락', c.context));
|
||||||
|
summary.appendChild(dl('목표', c.goal));
|
||||||
|
if (Array.isArray(c.criteria) && c.criteria.length > 0) {
|
||||||
|
const ul = c.criteria.map((x) => `- ${x}`).join('\n');
|
||||||
|
summary.appendChild(dl('기준', ul));
|
||||||
|
} else {
|
||||||
|
summary.appendChild(dl('기준', ''));
|
||||||
|
}
|
||||||
|
summary.appendChild(dl('형식', c.format));
|
||||||
|
card.appendChild(summary);
|
||||||
|
|
||||||
|
// ── 미해결 질문 ──
|
||||||
|
if (Array.isArray(c.openQuestions) && c.openQuestions.length > 0 && v.kind === 'questions') {
|
||||||
|
const qBlock = document.createElement('div');
|
||||||
|
qBlock.className = 'cal-questions';
|
||||||
|
const qHead = document.createElement('div');
|
||||||
|
qHead.className = 'cal-q-head';
|
||||||
|
qHead.textContent = '아래 질문에 한 메시지로 답해 주세요 (다 답해도, 일부만 답해도 OK):';
|
||||||
|
qBlock.appendChild(qHead);
|
||||||
|
const ul = document.createElement('ul');
|
||||||
|
for (const q of c.openQuestions) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = q;
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
qBlock.appendChild(ul);
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className = 'cal-hint';
|
||||||
|
hint.textContent = '답하지 않고 지금 그대로 시작하려면 아래 "그대로 진행" 버튼을 누르세요.';
|
||||||
|
qBlock.appendChild(hint);
|
||||||
|
card.appendChild(qBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 신뢰도 + 액션 버튼 ──
|
||||||
|
const confLabel = c.confidence === 'high' ? '신뢰도: high'
|
||||||
|
: c.confidence === 'medium' ? '신뢰도: medium' : '신뢰도: low';
|
||||||
|
const confEl = document.createElement('div');
|
||||||
|
confEl.className = 'cal-conf cal-conf-' + (c.confidence || 'low');
|
||||||
|
confEl.textContent = confLabel;
|
||||||
|
card.appendChild(confEl);
|
||||||
|
|
||||||
|
if (v.kind !== 'auto-proceed') {
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'cal-actions';
|
||||||
|
const proceedBtn = document.createElement('button');
|
||||||
|
proceedBtn.className = 'send-btn';
|
||||||
|
proceedBtn.textContent = '✅ 그대로 진행';
|
||||||
|
proceedBtn.onclick = () => {
|
||||||
|
vscode.postMessage({ type: 'respondCompanyAlignment', decision: 'proceed' });
|
||||||
|
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||||
|
proceedBtn.textContent = '✅ 진행 중...';
|
||||||
|
};
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.className = 'secondary-btn';
|
||||||
|
cancelBtn.textContent = '🛑 취소';
|
||||||
|
cancelBtn.title = '이 작업을 시작하지 않음';
|
||||||
|
cancelBtn.onclick = () => {
|
||||||
|
vscode.postMessage({ type: 'respondCompanyAlignment', decision: 'cancel' });
|
||||||
|
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||||
|
cancelBtn.textContent = '🛑 취소됨';
|
||||||
|
};
|
||||||
|
actions.appendChild(proceedBtn);
|
||||||
|
actions.appendChild(cancelBtn);
|
||||||
|
card.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
chatEl.appendChild(card);
|
||||||
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'companyAgents': {
|
case 'companyAgents': {
|
||||||
renderCompanyAgentCards(msg.value || {});
|
renderCompanyAgentCards(msg.value || {});
|
||||||
break;
|
break;
|
||||||
@@ -1854,6 +1990,8 @@
|
|||||||
agentId: '',
|
agentId: '',
|
||||||
modelOverride: '',
|
modelOverride: '',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
|
reviewWith: '',
|
||||||
|
reviewMaxRounds: 3,
|
||||||
instructionTemplate: '',
|
instructionTemplate: '',
|
||||||
loopBackPattern: '',
|
loopBackPattern: '',
|
||||||
loopBackTo: '',
|
loopBackTo: '',
|
||||||
@@ -1990,31 +2128,45 @@
|
|||||||
const agentSel = document.createElement('select');
|
const agentSel = document.createElement('select');
|
||||||
const _refillAgentSel = () => {
|
const _refillAgentSel = () => {
|
||||||
agentSel.innerHTML = '';
|
agentSel.innerHTML = '';
|
||||||
|
// "⚙️ CEO 자동 선택" 옵션을 항상 맨 위에 추가 — value=''로 빈
|
||||||
|
// agentId 저장됨. dispatcher가 보고 직군 후보 중 매번 CEO에게
|
||||||
|
// 적임자 결정 요청. 사용자 의도 "CEO가 배분"의 정공법.
|
||||||
|
const autoOpt = document.createElement('option');
|
||||||
|
autoOpt.value = '';
|
||||||
|
autoOpt.textContent = '⚙️ CEO 자동 선택 (이 직군 중)';
|
||||||
|
agentSel.appendChild(autoOpt);
|
||||||
const list = _activeAgentsByCategory[roleSel.value] || [];
|
const list = _activeAgentsByCategory[roleSel.value] || [];
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = '';
|
opt.value = '__no_agents__';
|
||||||
opt.textContent = '(이 직군의 활성 에이전트 없음)';
|
opt.textContent = '(이 직군의 활성 에이전트 없음)';
|
||||||
|
opt.disabled = true;
|
||||||
agentSel.appendChild(opt);
|
agentSel.appendChild(opt);
|
||||||
agentSel.disabled = true;
|
// CEO 자동 선택은 여전히 가능 — 활성 후보가 없으면 dispatcher에서
|
||||||
|
// no-active-agent-in-role 에러로 사용자에게 알린다.
|
||||||
} else {
|
} else {
|
||||||
agentSel.disabled = false;
|
|
||||||
for (const a of list) {
|
for (const a of list) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = a.id;
|
opt.value = a.id;
|
||||||
opt.textContent = `${a.emoji} ${a.name}`;
|
opt.textContent = `${a.emoji} ${a.name}`;
|
||||||
agentSel.appendChild(opt);
|
agentSel.appendChild(opt);
|
||||||
}
|
}
|
||||||
// 현재 stage의 agentId가 이 직군에 속하면 유지, 아니면 첫 번째.
|
}
|
||||||
const inList = list.some((a) => a.id === stage.agentId);
|
// 현재 stage.agentId가 빈값이면 CEO 자동 선택(default). 활성 후보에 있으면
|
||||||
agentSel.value = inList ? stage.agentId : list[0].id;
|
// 그 사람, 없으면 다시 CEO 자동으로 되돌림 — stale agentId 박제 방지.
|
||||||
stage.agentId = agentSel.value;
|
const aid = stage.agentId || '';
|
||||||
|
if (aid && list.some((a) => a.id === aid)) {
|
||||||
|
agentSel.value = aid;
|
||||||
|
} else {
|
||||||
|
agentSel.value = '';
|
||||||
|
stage.agentId = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
_refillAgentSel();
|
_refillAgentSel();
|
||||||
roleSel.onchange = () => {
|
roleSel.onchange = () => {
|
||||||
stage.roleCategory = roleSel.value;
|
stage.roleCategory = roleSel.value;
|
||||||
stage.agentId = _firstAgentOfCategory(roleSel.value);
|
// 직군 바뀌면 명시적 담당자 박힌 게 더 이상 의미 없음 — CEO 자동 선택으로 리셋.
|
||||||
|
stage.agentId = '';
|
||||||
_refillAgentSel();
|
_refillAgentSel();
|
||||||
};
|
};
|
||||||
agentSel.onchange = () => { stage.agentId = agentSel.value; };
|
agentSel.onchange = () => { stage.agentId = agentSel.value; };
|
||||||
@@ -2053,6 +2205,71 @@
|
|||||||
approvalWrap.appendChild(approvalText);
|
approvalWrap.appendChild(approvalText);
|
||||||
body.appendChild(approvalWrap);
|
body.appendChild(approvalWrap);
|
||||||
|
|
||||||
|
// ── 3-way 합의 검수 사이클 ──
|
||||||
|
// 작업자 산출물 → 검수자 의견 → CEO 메타-판단을 라운드로 반복해
|
||||||
|
// "작업자 + 검수자 + 대표" 셋이 모두 만족할 때까지 진행. 작은
|
||||||
|
// 모델에선 라운드를 많이 돌 수 있으므로 maxRounds 안전망 필요.
|
||||||
|
const reviewWrap = document.createElement('label');
|
||||||
|
reviewWrap.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:10px; color:var(--text-dim); cursor:pointer; margin-top:4px;';
|
||||||
|
const reviewCb = document.createElement('input');
|
||||||
|
reviewCb.type = 'checkbox';
|
||||||
|
reviewCb.checked = !!stage.reviewWith;
|
||||||
|
const reviewText = document.createElement('span');
|
||||||
|
reviewText.textContent = '🔍 검수 사이클 (작업자 + 검수자 + CEO 합의)';
|
||||||
|
reviewText.title = '체크하면 작업자 산출물 직후 검수자 + CEO 메타-판단을 라운드로 돌려 셋이 모두 만족할 때만 통과';
|
||||||
|
reviewWrap.appendChild(reviewCb);
|
||||||
|
reviewWrap.appendChild(reviewText);
|
||||||
|
body.appendChild(reviewWrap);
|
||||||
|
|
||||||
|
// 검수자 선택 + 최대 라운드 (체크박스 ON일 때만 노출)
|
||||||
|
const reviewDetail = document.createElement('div');
|
||||||
|
reviewDetail.style.cssText = 'display:none; gap:8px; flex-wrap:wrap; align-items:center; font-size:10px; color:var(--text-dim); margin: 2px 0 4px 22px;';
|
||||||
|
const inspLbl = document.createElement('label'); inspLbl.textContent = '검수자:';
|
||||||
|
const inspSel = document.createElement('select');
|
||||||
|
// 옵션: 직군 inspector 자동 + 활성 inspector 직군 에이전트 + 'any active agent of given category' 정도면 충분
|
||||||
|
const inspectorOpts = (_activeAgentsByCategory['inspector'] || []);
|
||||||
|
const autoOpt = document.createElement('option');
|
||||||
|
autoOpt.value = 'inspector';
|
||||||
|
autoOpt.textContent = '⚙️ 감리 직군 자동';
|
||||||
|
inspSel.appendChild(autoOpt);
|
||||||
|
for (const a of inspectorOpts) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = `agent:${a.id}`;
|
||||||
|
opt.textContent = `${a.emoji} ${a.name}`;
|
||||||
|
inspSel.appendChild(opt);
|
||||||
|
}
|
||||||
|
// 현재값 적용
|
||||||
|
inspSel.value = stage.reviewWith || 'inspector';
|
||||||
|
inspSel.onchange = () => {
|
||||||
|
stage.reviewWith = inspSel.value;
|
||||||
|
};
|
||||||
|
const roundLbl = document.createElement('label'); roundLbl.textContent = '최대 라운드:';
|
||||||
|
const roundInput = document.createElement('input');
|
||||||
|
roundInput.type = 'number'; roundInput.min = '1'; roundInput.max = '10';
|
||||||
|
roundInput.style.cssText = 'width:55px; padding:2px 4px; background:var(--bg); color:var(--text-primary); border:1px solid var(--border); border-radius:4px;';
|
||||||
|
roundInput.value = String(stage.reviewMaxRounds || 3);
|
||||||
|
roundInput.oninput = () => {
|
||||||
|
const v = parseInt(roundInput.value, 10);
|
||||||
|
stage.reviewMaxRounds = Number.isFinite(v) ? Math.max(1, Math.min(10, v)) : 3;
|
||||||
|
};
|
||||||
|
reviewDetail.appendChild(inspLbl); reviewDetail.appendChild(inspSel);
|
||||||
|
reviewDetail.appendChild(roundLbl); reviewDetail.appendChild(roundInput);
|
||||||
|
body.appendChild(reviewDetail);
|
||||||
|
|
||||||
|
const _syncReviewDetail = () => {
|
||||||
|
reviewDetail.style.display = reviewCb.checked ? 'flex' : 'none';
|
||||||
|
};
|
||||||
|
_syncReviewDetail();
|
||||||
|
reviewCb.onchange = () => {
|
||||||
|
if (reviewCb.checked) {
|
||||||
|
stage.reviewWith = inspSel.value || 'inspector';
|
||||||
|
if (!stage.reviewMaxRounds) stage.reviewMaxRounds = 3;
|
||||||
|
} else {
|
||||||
|
stage.reviewWith = '';
|
||||||
|
}
|
||||||
|
_syncReviewDetail();
|
||||||
|
};
|
||||||
|
|
||||||
// 지시 텍스트 + 토큰 버튼
|
// 지시 텍스트 + 토큰 버튼
|
||||||
const instrLabelDiv = document.createElement('div');
|
const instrLabelDiv = document.createElement('div');
|
||||||
instrLabelDiv.className = 'psc-field-label';
|
instrLabelDiv.className = 'psc-field-label';
|
||||||
@@ -2175,6 +2392,8 @@
|
|||||||
agentId: s.agentId || '',
|
agentId: s.agentId || '',
|
||||||
modelOverride: s.modelOverride || '',
|
modelOverride: s.modelOverride || '',
|
||||||
requiresApproval: !!s.requiresApproval,
|
requiresApproval: !!s.requiresApproval,
|
||||||
|
reviewWith: s.reviewWith || '',
|
||||||
|
reviewMaxRounds: s.reviewMaxRounds || 3,
|
||||||
instructionTemplate: s.instructionTemplate || '',
|
instructionTemplate: s.instructionTemplate || '',
|
||||||
loopBackPattern: s.loopBackPattern || '',
|
loopBackPattern: s.loopBackPattern || '',
|
||||||
loopBackTo: s.loopBackTo || '',
|
loopBackTo: s.loopBackTo || '',
|
||||||
@@ -2241,15 +2460,16 @@
|
|||||||
if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자로 시작, /-/_).';
|
if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자로 시작, /-/_).';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 각 stage 검증: 라벨 + 담당 에이전트 필수.
|
// 각 stage 검증: 라벨 + (담당자 또는 직군) 중 하나는 반드시.
|
||||||
|
// CEO 자동 선택 모드(agentId 비어 있음)면 직군이 필수.
|
||||||
for (let i = 0; i < _editStages.length; i++) {
|
for (let i = 0; i < _editStages.length; i++) {
|
||||||
const s = _editStages[i];
|
const s = _editStages[i];
|
||||||
if (!s.label?.trim()) {
|
if (!s.label?.trim()) {
|
||||||
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 이름이 비어 있습니다.`;
|
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 이름이 비어 있습니다.`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!s.agentId) {
|
if (!s.agentId && !s.roleCategory) {
|
||||||
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당 에이전트를 지정하세요. 해당 직군에 활성 에이전트가 없으면 관리 패널에서 활성화 먼저.`;
|
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당자(또는 직군)을 지정하세요.`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2260,14 +2480,22 @@
|
|||||||
const out = {
|
const out = {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
label: s.label.trim(),
|
label: s.label.trim(),
|
||||||
agentId: s.agentId,
|
// 빈 agentId(= CEO 자동 선택) 일 땐 필드 자체를 빼서 backend에
|
||||||
|
// optional로 전달. dispatcher가 roleCategory 보고 실시간 결정.
|
||||||
roleCategory: s.roleCategory,
|
roleCategory: s.roleCategory,
|
||||||
instructionTemplate: s.instructionTemplate || '',
|
instructionTemplate: s.instructionTemplate || '',
|
||||||
};
|
};
|
||||||
|
if (s.agentId && s.agentId.trim()) {
|
||||||
|
out.agentId = s.agentId.trim();
|
||||||
|
}
|
||||||
if (s.modelOverride && s.modelOverride.trim()) {
|
if (s.modelOverride && s.modelOverride.trim()) {
|
||||||
out.modelOverride = s.modelOverride.trim();
|
out.modelOverride = s.modelOverride.trim();
|
||||||
}
|
}
|
||||||
if (s.requiresApproval) out.requiresApproval = true;
|
if (s.requiresApproval) out.requiresApproval = true;
|
||||||
|
if (s.reviewWith && s.reviewWith.trim()) {
|
||||||
|
out.reviewWith = s.reviewWith.trim();
|
||||||
|
out.reviewMaxRounds = s.reviewMaxRounds || 3;
|
||||||
|
}
|
||||||
if (s.loopBackPattern && s.loopBackTo) {
|
if (s.loopBackPattern && s.loopBackTo) {
|
||||||
out.loopBackPattern = s.loopBackPattern;
|
out.loopBackPattern = s.loopBackPattern;
|
||||||
out.loopBackTo = s.loopBackTo;
|
out.loopBackTo = s.loopBackTo;
|
||||||
@@ -2377,6 +2605,214 @@
|
|||||||
window.__renderCompanyPipelines = renderCompanyPipelines;
|
window.__renderCompanyPipelines = renderCompanyPipelines;
|
||||||
window.__closePipelineEditor = _closePipelineEditor;
|
window.__closePipelineEditor = _closePipelineEditor;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Pixel Office 렌더러 — 백엔드의 pixelOfficeUpdate 메시지를 받아 캐릭터,
|
||||||
|
// 정보 패널, 말풍선 큐를 갱신. 어떤 상태도 그대로 그리기만 함 (read-only).
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
(function setupPixelOffice() {
|
||||||
|
const root = document.getElementById('pixelOffice');
|
||||||
|
if (!root) return;
|
||||||
|
const collapseBtn = document.getElementById('poCollapseBtn');
|
||||||
|
const expandBtn = document.getElementById('poExpandBtn');
|
||||||
|
const head = document.querySelector('#pixelOffice .po-head');
|
||||||
|
const charEl = document.getElementById('poChar');
|
||||||
|
const charEmoji = document.getElementById('poCharEmoji');
|
||||||
|
const charProp = document.getElementById('poCharProp');
|
||||||
|
const bubblesEl = document.getElementById('poBubbles');
|
||||||
|
const progressBar = document.getElementById('poProgressBar');
|
||||||
|
const statusLabel = document.getElementById('poStatusLabel');
|
||||||
|
const statusVal = document.getElementById('poStatusVal');
|
||||||
|
const agentName = document.getElementById('poAgentName');
|
||||||
|
const taskEl = document.getElementById('poTask');
|
||||||
|
const stepEl = document.getElementById('poStep');
|
||||||
|
const nextStepRow = document.getElementById('poNextStepRow');
|
||||||
|
const nextStepEl = document.getElementById('poNextStep');
|
||||||
|
const messageRow = document.getElementById('poMessageRow');
|
||||||
|
const messageEl = document.getElementById('poMessage');
|
||||||
|
const needInputSection = document.getElementById('poNeedInputSection');
|
||||||
|
const needInputList = document.getElementById('poNeedInputList');
|
||||||
|
const approvalSection = document.getElementById('poApprovalSection');
|
||||||
|
const approvalText = document.getElementById('poApprovalText');
|
||||||
|
const contractSection = document.getElementById('poContractSection');
|
||||||
|
const contractEl = document.getElementById('poContract');
|
||||||
|
const logsSection = document.getElementById('poLogsSection');
|
||||||
|
const logsEl = document.getElementById('poLogs');
|
||||||
|
|
||||||
|
// 상태별 캐릭터·소품 매핑 — 픽셀아트 대신 이모지 조합으로.
|
||||||
|
const STATUS_VIS = {
|
||||||
|
idle: { emoji: '🧑💼', prop: '' },
|
||||||
|
intake: { emoji: '🧑💼', prop: '📨' },
|
||||||
|
analyzing: { emoji: '🧐', prop: '🔍' },
|
||||||
|
need_clarification: { emoji: '🤔', prop: '❓' },
|
||||||
|
contract_ready: { emoji: '🧑💼', prop: '📋' },
|
||||||
|
planning: { emoji: '🧑💼', prop: '📝' },
|
||||||
|
executing: { emoji: '🧑💻', prop: '⚙️' },
|
||||||
|
reviewing: { emoji: '🧐', prop: '✅' },
|
||||||
|
waiting_approval: { emoji: '🧑💼', prop: '🛑' },
|
||||||
|
error: { emoji: '😵', prop: '⚠️' },
|
||||||
|
done: { emoji: '😎', prop: '☕' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 말풍선 큐 ──
|
||||||
|
// 큐 크기 제한 + 자동 사라짐 타이머. 같은 텍스트 연속 등장 시 1개로 합침.
|
||||||
|
let cfg = { enabled: true, bubblesEnabled: true, maxVisibleBubbles: 3, bubbleDurationMs: 4500 };
|
||||||
|
const bubbleQueue = []; // { el, timer }
|
||||||
|
let lastBubbleText = '';
|
||||||
|
|
||||||
|
const collapseToggle = () => {
|
||||||
|
const cur = root.getAttribute('data-collapsed') === 'true';
|
||||||
|
root.setAttribute('data-collapsed', cur ? 'false' : 'true');
|
||||||
|
};
|
||||||
|
if (collapseBtn) collapseBtn.onclick = (e) => { e.stopPropagation(); collapseToggle(); };
|
||||||
|
if (expandBtn) expandBtn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 백엔드에 전체보기 panel 열기 요청.
|
||||||
|
vscode.postMessage({ type: 'openPixelOfficePanel' });
|
||||||
|
};
|
||||||
|
// head 영역 자체 클릭으로도 토글 (버튼 외 영역).
|
||||||
|
if (head) head.addEventListener('click', (e) => {
|
||||||
|
if (e.target instanceof HTMLElement && (e.target.closest('.po-collapse') || e.target.closest('.po-expand'))) return;
|
||||||
|
collapseToggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropOldestBubble = () => {
|
||||||
|
const first = bubbleQueue.shift();
|
||||||
|
if (!first) return;
|
||||||
|
if (first.timer) clearTimeout(first.timer);
|
||||||
|
first.el.classList.add('po-bubble-fading');
|
||||||
|
setTimeout(() => { try { first.el.remove(); } catch {} }, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushBubble = (b) => {
|
||||||
|
if (!cfg.bubblesEnabled) return;
|
||||||
|
if (!b || !b.text) return;
|
||||||
|
if (b.text === lastBubbleText) return; // 연속 중복 차단
|
||||||
|
lastBubbleText = b.text;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'po-bubble po-bubble-' + (b.type || 'status');
|
||||||
|
el.textContent = b.text;
|
||||||
|
bubblesEl.appendChild(el);
|
||||||
|
const duration = b.durationMs || cfg.bubbleDurationMs || 4500;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const idx = bubbleQueue.findIndex((x) => x.el === el);
|
||||||
|
if (idx >= 0) {
|
||||||
|
bubbleQueue.splice(idx, 1);
|
||||||
|
el.classList.add('po-bubble-fading');
|
||||||
|
setTimeout(() => { try { el.remove(); } catch {} }, 300);
|
||||||
|
}
|
||||||
|
}, duration);
|
||||||
|
bubbleQueue.push({ el, timer });
|
||||||
|
while (bubbleQueue.length > Math.max(1, cfg.maxVisibleBubbles)) {
|
||||||
|
dropOldestBubble();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setText = (el, val) => { if (el) el.textContent = (val == null || val === '') ? '—' : String(val); };
|
||||||
|
|
||||||
|
const apply = (payload) => {
|
||||||
|
cfg = Object.assign(cfg, payload && payload.config ? payload.config : {});
|
||||||
|
root.setAttribute('data-enabled', cfg.enabled ? 'true' : 'false');
|
||||||
|
if (!cfg.enabled) return;
|
||||||
|
const state = payload && payload.state;
|
||||||
|
if (state) {
|
||||||
|
const vis = STATUS_VIS[state.status] || STATUS_VIS.idle;
|
||||||
|
if (charEmoji) charEmoji.textContent = vis.emoji;
|
||||||
|
if (charProp) charProp.textContent = vis.prop;
|
||||||
|
root.setAttribute('data-status', state.status || 'idle');
|
||||||
|
// 상태 라벨 색상 클래스 새로.
|
||||||
|
if (statusLabel) {
|
||||||
|
statusLabel.className = 'po-status-label po-status-' + (state.status || 'idle');
|
||||||
|
statusLabel.textContent = state.status || 'idle';
|
||||||
|
}
|
||||||
|
setText(statusVal, state.status);
|
||||||
|
setText(agentName, state.agentName);
|
||||||
|
setText(taskEl, state.currentTask);
|
||||||
|
setText(stepEl, state.currentStep);
|
||||||
|
if (state.nextStep) {
|
||||||
|
nextStepRow.style.display = '';
|
||||||
|
setText(nextStepEl, state.nextStep);
|
||||||
|
} else {
|
||||||
|
nextStepRow.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (state.message) {
|
||||||
|
messageRow.style.display = '';
|
||||||
|
setText(messageEl, state.message);
|
||||||
|
} else {
|
||||||
|
messageRow.style.display = 'none';
|
||||||
|
}
|
||||||
|
// Progress
|
||||||
|
if (progressBar) {
|
||||||
|
const pct = typeof state.progress === 'number'
|
||||||
|
? Math.round(Math.max(0, Math.min(1, state.progress)) * 100)
|
||||||
|
: 0;
|
||||||
|
progressBar.style.width = pct + '%';
|
||||||
|
}
|
||||||
|
// Need input
|
||||||
|
if (Array.isArray(state.needUserInput) && state.needUserInput.length > 0) {
|
||||||
|
needInputSection.style.display = '';
|
||||||
|
needInputList.innerHTML = '';
|
||||||
|
for (const q of state.needUserInput) {
|
||||||
|
const li = document.createElement('li'); li.textContent = q;
|
||||||
|
needInputList.appendChild(li);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
needInputSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
// Approval
|
||||||
|
if (state.awaitingApproval) {
|
||||||
|
approvalSection.style.display = '';
|
||||||
|
setText(approvalText, state.awaitingApproval);
|
||||||
|
} else {
|
||||||
|
approvalSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
// Contract
|
||||||
|
const c = state.requirementContract;
|
||||||
|
if (c && (c.goal || c.context || (c.criteria && c.criteria.length) || c.format)) {
|
||||||
|
contractSection.style.display = '';
|
||||||
|
contractEl.innerHTML = '';
|
||||||
|
const addRow = (k, v) => {
|
||||||
|
if (!v || (Array.isArray(v) && v.length === 0)) return;
|
||||||
|
const ke = document.createElement('div');
|
||||||
|
ke.className = 'po-contract-key'; ke.textContent = k;
|
||||||
|
const ve = document.createElement('div');
|
||||||
|
ve.className = 'po-contract-val';
|
||||||
|
ve.textContent = Array.isArray(v) ? v.map((x) => '• ' + x).join('\n') : String(v);
|
||||||
|
ve.style.whiteSpace = 'pre-line';
|
||||||
|
contractEl.appendChild(ke);
|
||||||
|
contractEl.appendChild(ve);
|
||||||
|
};
|
||||||
|
addRow('Goal', c.goal);
|
||||||
|
addRow('Ctx', c.context);
|
||||||
|
addRow('Crit', c.criteria);
|
||||||
|
addRow('Fmt', c.format);
|
||||||
|
if (c.confidence) addRow('Conf', c.confidence);
|
||||||
|
} else {
|
||||||
|
contractSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
// Recent logs
|
||||||
|
if (Array.isArray(state.recentLogs) && state.recentLogs.length > 0) {
|
||||||
|
logsSection.style.display = '';
|
||||||
|
logsEl.innerHTML = '';
|
||||||
|
for (const line of state.recentLogs) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = line;
|
||||||
|
logsEl.appendChild(d);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logsSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bubbles
|
||||||
|
if (Array.isArray(payload?.bubbles)) {
|
||||||
|
for (const b of payload.bubbles) pushBubble(b);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.__pixelOfficeApply = apply;
|
||||||
|
|
||||||
|
// webview 로드 직후 백엔드 캐시 상태 요청.
|
||||||
|
try { vscode.postMessage({ type: 'getPixelOfficeState' }); } catch {}
|
||||||
|
})();
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// 이어서 진행 가능 세션 렌더링.
|
// 이어서 진행 가능 세션 렌더링.
|
||||||
//
|
//
|
||||||
@@ -3063,6 +3499,43 @@
|
|||||||
} else if (ev.phase === 'stage-loop') {
|
} else if (ev.phase === 'stage-loop') {
|
||||||
card.innerHTML = `<div class="cph-head">🔁 Stage 재시도</div>
|
card.innerHTML = `<div class="cph-head">🔁 Stage 재시도</div>
|
||||||
<div class="cph-meta">${escAttr(ev.from)} → ${escAttr(ev.to)} (반복 ${ev.iteration}회)</div>`;
|
<div class="cph-meta">${escAttr(ev.from)} → ${escAttr(ev.to)} (반복 ${ev.iteration}회)</div>`;
|
||||||
|
} else if (ev.phase === 'review-start') {
|
||||||
|
// 검수 사이클 시작 — 같은 stageId에 라운드를 누적할 컨테이너 카드.
|
||||||
|
// 이후 review-round 이벤트가 이 카드의 .rev-rounds 자식 안에 한 줄씩 append.
|
||||||
|
card.className += ' review';
|
||||||
|
card.dataset.stageId = ev.stageId;
|
||||||
|
card.dataset.reviewMaxRounds = String(ev.maxRounds || 3);
|
||||||
|
card.innerHTML = `<div class="cph-head">🔍 <strong>${escAttr(ev.stageLabel || ev.stageId)}</strong> 검수 사이클 시작 <span class="cph-meta">검수자: ${escAttr(ev.inspectorAgentId)} · 최대 ${ev.maxRounds}라운드</span></div>
|
||||||
|
<div class="rev-rounds"></div>`;
|
||||||
|
} else if (ev.phase === 'review-round') {
|
||||||
|
// 이미 그려둔 review-start 카드 안에 라운드 한 줄을 누적.
|
||||||
|
const target = chatEl.querySelector(`.company-phase-card.review[data-stage-id="${CSS.escape(ev.stageId)}"] .rev-rounds`);
|
||||||
|
if (target) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'rev-round';
|
||||||
|
const inspIcon = ev.inspectorVerdict === 'pass' ? '✅' : ev.inspectorVerdict === 'revise' ? '❌' : '❓';
|
||||||
|
const ceoIcon = ev.ceoVerdict === 'pass' ? '✅' : ev.ceoVerdict === 'abort' ? '🛑' : ev.ceoVerdict === 'revise' ? '🔁' : '❓';
|
||||||
|
row.innerHTML = `<div class="rev-round-head">라운드 ${ev.round} <span class="cph-meta">${(ev.durationMs/1000).toFixed(1)}s</span></div>
|
||||||
|
<div class="rev-line"><span class="rev-actor">${inspIcon} 검수</span><span class="rev-body">${fmt((ev.inspectorText || '').slice(0, 1500))}</span></div>
|
||||||
|
<div class="rev-line"><span class="rev-actor">${ceoIcon} CEO</span><span class="rev-body">${fmt((ev.ceoText || '').slice(0, 1000))}</span></div>`;
|
||||||
|
target.appendChild(row);
|
||||||
|
}
|
||||||
|
return; // 새 카드 만들지 않음
|
||||||
|
} else if (ev.phase === 'review-end') {
|
||||||
|
// 사이클 종료 라벨을 컨테이너 카드 끝에 붙임 — 별도 카드 X.
|
||||||
|
const target = chatEl.querySelector(`.company-phase-card.review[data-stage-id="${CSS.escape(ev.stageId)}"]`);
|
||||||
|
if (target) {
|
||||||
|
const tail = document.createElement('div');
|
||||||
|
tail.className = 'rev-end';
|
||||||
|
const label = ev.final === 'pass'
|
||||||
|
? `✅ 합의 통과 (${ev.rounds}라운드)`
|
||||||
|
: ev.final === 'maxed-out'
|
||||||
|
? `⚠️ 최대 라운드 도달 — 현 결과로 진행 (${ev.rounds}라운드)`
|
||||||
|
: `🛑 사이클 중단 (${ev.rounds}라운드)`;
|
||||||
|
tail.textContent = label;
|
||||||
|
target.appendChild(tail);
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else if (ev.phase === 'awaiting-approval') {
|
} else if (ev.phase === 'awaiting-approval') {
|
||||||
// 승인 게이트 카드 — 사용자가 클릭할 때까지 대기.
|
// 승인 게이트 카드 — 사용자가 클릭할 때까지 대기.
|
||||||
card.className += ' approval';
|
card.className += ' approval';
|
||||||
|
|||||||
+58
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "astra",
|
"name": "astra",
|
||||||
"displayName": "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.",
|
"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.1",
|
"version": "2.2.3",
|
||||||
"publisher": "g1nation",
|
"publisher": "g1nation",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
@@ -130,6 +130,10 @@
|
|||||||
{
|
{
|
||||||
"command": "g1nation.company.openSessions",
|
"command": "g1nation.company.openSessions",
|
||||||
"title": "Astra: Open 1인 기업 Sessions Folder"
|
"title": "Astra: Open 1인 기업 Sessions Folder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "g1nation.company.pixelOffice.open",
|
||||||
|
"title": "Astra: Open Pixel Office (Full Screen)"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"keybindings": [
|
"keybindings": [
|
||||||
@@ -421,6 +425,59 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"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."
|
"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.company.intentClassifierModel": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"description": "Model used to classify whether an incoming chat message in 1인 기업 모드 is a (a) casual chat / question, (b) follow-up on the previous round, or (c) a brand-new task that should trigger the full work pipeline. Empty → uses g1nation.defaultModel. Pick a fast small model (e.g. gemma 4 e2b) so the classifier doesn't add latency before every chat send. The classifier runs once per user message and returns a one-token-ish JSON verdict, so even slow hardware sees minimal overhead."
|
||||||
|
},
|
||||||
|
"g1nation.company.disableIntentClassifier": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Bypass the intent classifier and always run the full work pipeline on every chat message in 1인 기업 모드 (legacy behaviour). Enable this only if you want every input — including 'thanks', 'show me X again' — to dispatch all agents. Off by default because most chat messages aren't new work and shouldn't burn a full pipeline."
|
||||||
|
},
|
||||||
|
"g1nation.company.autoSelectPipeline": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Let the intent classifier *automatically switch* to the pipeline it recommends for this turn (e.g. short '기획서까지만' for a planning ask, full '풀 프로덕트' for an end-to-end product). Your explicitly-activated pipeline is bypassed for the round but the activation itself isn't changed. On by default — the classifier's read of the user's intent (especially explicit signals like '기획만'·'디자인만') should be honoured. Set to false if you'd rather always run the pipeline you activated yourself."
|
||||||
|
},
|
||||||
|
"g1nation.company.intentAlignmentMode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["off", "smart", "strict"],
|
||||||
|
"default": "smart",
|
||||||
|
"description": "Intent Alignment — turn user prompts into an explicit Requirement Contract (C-G-C-F-Q) before dispatching a pipeline. 'off' = legacy, pipeline runs immediately. 'smart' (default) = run when confidence is high, else show a confirmation card; ask up to N rounds of clarifying questions if information is missing. 'strict' = always show the contract card and require user confirmation, regardless of confidence. Goal: stop agents from silently guessing at the user's mental model."
|
||||||
|
},
|
||||||
|
"g1nation.company.intentAlignmentMaxRounds": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 5,
|
||||||
|
"default": 3,
|
||||||
|
"description": "Maximum back-and-forth rounds the Intent Alignment analyzer is allowed to ask before forcing a 'confirm or cancel' card (it stops asking new questions and shows the current contract for user approval). Each round = one LLM call. Default 3."
|
||||||
|
},
|
||||||
|
"g1nation.selfReflector.enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Self-Reflector Phase A — append a [Self-Reflector Check] block at the end of every substantive LLM answer (Consistency / Completeness / Accuracy, plus References / Paths for code answers). Zero extra LLM calls — the rule lives in the system prompt and the model self-imposes the checklist. Turns response quality up by making the verification step explicit. Disable for purely casual / chat-only usage."
|
||||||
|
},
|
||||||
|
"g1nation.selfReflector.externalVerification": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Self-Reflector Phase B — after every 1인 기업 specialist response, run a *separate* LLM call to verify the output from an outside-context perspective (catches the 'same model self-validates' blind spot). Failed checks trigger one auto-revise round. Off by default — adds +1 LLM call per dispatched stage."
|
||||||
|
},
|
||||||
|
"g1nation.selfReflector.executionVerification": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Self-Reflector Phase C — after a code file is created via <create_file>, automatically run the language's syntax check (Python: py_compile, JS: node --check, TS: project tsc --noEmit). Failures are surfaced in the action report so the user (and the agent on a follow-up) can see exactly what broke. Requires the language toolchain installed on the user's machine. Off by default."
|
||||||
|
},
|
||||||
|
"g1nation.company.pixelOffice.enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show the Pixel Office visualisation panel above the chat — a small pixel-office-style display that mirrors the agent's current pipeline status (analyzing, need_clarification, executing, reviewing, waiting_approval, done, etc.) and the current task / contract / open questions. UI layer only; turning it off does not change any agent behaviour."
|
||||||
|
},
|
||||||
|
"g1nation.company.pixelOffice.bubbles": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Show short comic-style speech bubbles above the Pixel Office character on status changes / key events (e.g. '코드 들어간다', '잠깐, 이건 다시 보자', '좋아, 끝났다!'). Bubbles are purely narrative — they never influence the agent's decisions. Disable for a quieter UI."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1152,6 +1152,31 @@ export class AgentExecutor {
|
|||||||
this.statusBarManager.updateStatus(AgentStatus.Executing);
|
this.statusBarManager.updateStatus(AgentStatus.Executing);
|
||||||
// Action tags are honored only from the visible final answer — never from hidden reasoning.
|
// Action tags are honored only from the visible final answer — never from hidden reasoning.
|
||||||
const report = await this.executeActions(cleanedVisible, rootPath, activeBrain);
|
const report = await this.executeActions(cleanedVisible, rootPath, activeBrain);
|
||||||
|
// Self-Reflector Phase C — 일반 채팅 경로에서도 코드 파일 생성 직후
|
||||||
|
// syntax 체크 실행. 옵션 OFF면 통째로 skip.
|
||||||
|
try {
|
||||||
|
const cfgC = getConfig();
|
||||||
|
if (cfgC.selfReflectorExecutionEnabled && report.length > 0) {
|
||||||
|
const { verifyCreatedFiles } = await import('./features/selfReflector/selfReflectorExecution');
|
||||||
|
const extra = await verifyCreatedFiles(report, rootPath);
|
||||||
|
if (extra.length > 0) report.push(...extra);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('selfReflector.C (chat): hook failed; continuing.', { error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
// Hollow code 검사 — selfReflectorEnabled가 켜져 있으면 syntax 통과
|
||||||
|
// 한 파일도 빈 깡통은 잡는다. 일반 채팅 경로에선 자동 retry 없이
|
||||||
|
// 경고만 — 사용자가 직접 보고 다시 요청할 수 있으니 충분.
|
||||||
|
try {
|
||||||
|
const cfgH = getConfig();
|
||||||
|
if (cfgH.selfReflectorEnabled && report.length > 0) {
|
||||||
|
const { verifyHollow } = await import('./features/selfReflector/selfReflectorHollow');
|
||||||
|
const hollowRes = verifyHollow(report, rootPath);
|
||||||
|
if (hollowRes.hasHollow) report.push(...hollowRes.extraLines);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('selfReflector.hollow (chat): hook failed; continuing.', { error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
if (!assistantContent.trim() && report.length === 0) {
|
if (!assistantContent.trim() && report.length === 0) {
|
||||||
const promptCharCount = messagesForRequest.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
|
const promptCharCount = messagesForRequest.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
|
||||||
logError('Model returned an empty response without actions.', {
|
logError('Model returned an empty response without actions.', {
|
||||||
|
|||||||
@@ -72,6 +72,67 @@ export interface IAgentConfig {
|
|||||||
* true(기본): Reflector가 plan/research를 비판적으로 검토한 critique을 Writer에 주입.
|
* true(기본): Reflector가 plan/research를 비판적으로 검토한 critique을 Writer에 주입.
|
||||||
* false: 기존 3단계(Planner→Researcher→Writer) 그대로 — 1 LLM 호출 절약 (저성능 모델/저지연 우선 시).
|
* false: 기존 3단계(Planner→Researcher→Writer) 그대로 — 1 LLM 호출 절약 (저성능 모델/저지연 우선 시).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Model id used by the 1인 기업 mode intent classifier (route message to
|
||||||
|
* pipeline vs casual chat). Empty → falls back to `defaultModel`. Recommended
|
||||||
|
* a fast small model (gemma e2b 등) so classification adds <1 s per send.
|
||||||
|
*/
|
||||||
|
companyIntentClassifierModel: string;
|
||||||
|
/**
|
||||||
|
* Bypass the intent classifier and always run the full pipeline. Legacy
|
||||||
|
* behaviour. Off by default because chat / question / thanks shouldn't
|
||||||
|
* dispatch all agents.
|
||||||
|
*/
|
||||||
|
companyDisableIntentClassifier: boolean;
|
||||||
|
/**
|
||||||
|
* 분류기가 추천한 파이프라인으로 *이번 turn만* 자동 전환할지. 켜면
|
||||||
|
* 사용자가 명시적으로 활성화해 둔 파이프라인보다 분류기 추천이 우선.
|
||||||
|
* 끄면 분류기 추천은 채팅 라벨에만 표시되고 dispatch는 사용자 활성
|
||||||
|
* 파이프라인 그대로. 처음 써 보는 사용자는 끈 채 추천만 보고 점차
|
||||||
|
* 신뢰 생기면 켜는 흐름을 권장 — 기본 false.
|
||||||
|
*/
|
||||||
|
companyAutoSelectPipeline: boolean;
|
||||||
|
/**
|
||||||
|
* Intent Alignment 모드. new_task 발생 시 사용자 의도를 C-G-C-F-Q로
|
||||||
|
* 정리하는 단계를 어떻게 다룰지.
|
||||||
|
* - 'off' : alignment 비활성. 분류기가 new_task 라고 하면 곧장 pipeline.
|
||||||
|
* - 'smart' : 기본값. confidence high면 자동 진행, medium/low면 사용자 확인.
|
||||||
|
* - 'strict' : confidence 무관 항상 사용자에게 contract 확인 카드 띄움.
|
||||||
|
*/
|
||||||
|
companyIntentAlignmentMode: 'off' | 'smart' | 'strict';
|
||||||
|
/** alignment 라운드 최대 횟수 (질문→답변 사이클). 1~5. */
|
||||||
|
companyIntentAlignmentMaxRounds: number;
|
||||||
|
/**
|
||||||
|
* Pixel Office 시각화 패널을 사이드바에 표시할지 여부. UI layer 전용 —
|
||||||
|
* 끈다고 Agent 행동이 바뀌지 않는다. Off면 webview는 패널을 숨기고
|
||||||
|
* 백엔드도 broadcast 자체를 skip해서 자원 절약.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Self-Reflector Phase A — 모든 LLM 응답 끝에 [Self-Reflector Check]
|
||||||
|
* 자기검증 블록을 자동으로 붙이게 한다. 추가 LLM 콜 없음. 본질적으로
|
||||||
|
* 응답 품질 안전망이라 끌 이유는 적지만, 잡담 위주 환경에서 노이즈로
|
||||||
|
* 느껴진다면 꺼둘 수 있다.
|
||||||
|
*/
|
||||||
|
selfReflectorEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* Self-Reflector Phase B — 회사 모드 specialist 응답 직후 *분리된 콘텍스트*
|
||||||
|
* 에서 LLM 한 번 더 호출해 외부 시각으로 검증. 실패 시 1회 retry. 비용
|
||||||
|
* 추가되므로 기본 OFF.
|
||||||
|
*/
|
||||||
|
selfReflectorExternalEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* Self-Reflector Phase C — 코드 산출물에 한해 syntax/lint를 실제로 돌려
|
||||||
|
* 실행 기반 검증. Python: py_compile, JS: node --check, TS: tsc --noEmit.
|
||||||
|
* 실패 시 에러를 응답에 첨부. 기본 OFF — 사용자 환경에 toolchain이
|
||||||
|
* 깔려 있어야 의미가 있다.
|
||||||
|
*/
|
||||||
|
selfReflectorExecutionEnabled: boolean;
|
||||||
|
companyPixelOfficeEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* Pixel Office의 캐릭터 말풍선 연출을 켤지. enabled가 true이고 이 값도
|
||||||
|
* true일 때만 말풍선이 생성된다. 시끄럽게 느껴지면 사용자가 끌 수 있게.
|
||||||
|
*/
|
||||||
|
companyPixelOfficeBubbles: boolean;
|
||||||
enableReflection: boolean;
|
enableReflection: boolean;
|
||||||
/**
|
/**
|
||||||
* [Self-Reflection → Knowledge] Reflector critique 중 의미 있는 발견을 brain의
|
* [Self-Reflection → Knowledge] Reflector critique 중 의미 있는 발견을 brain의
|
||||||
@@ -166,6 +227,19 @@ export function getConfig(): IAgentConfig {
|
|||||||
knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round(
|
knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round(
|
||||||
cfg.get<number>('knowledgeMix.secondBrainWeight', 50)
|
cfg.get<number>('knowledgeMix.secondBrainWeight', 50)
|
||||||
))),
|
))),
|
||||||
|
companyIntentClassifierModel: (cfg.get<string>('company.intentClassifierModel', '') || '').trim(),
|
||||||
|
companyDisableIntentClassifier: cfg.get<boolean>('company.disableIntentClassifier', false),
|
||||||
|
companyAutoSelectPipeline: cfg.get<boolean>('company.autoSelectPipeline', true),
|
||||||
|
companyIntentAlignmentMode: ((): 'off' | 'smart' | 'strict' => {
|
||||||
|
const v = (cfg.get<string>('company.intentAlignmentMode', 'smart') || 'smart').trim().toLowerCase();
|
||||||
|
return v === 'off' || v === 'strict' ? v : 'smart';
|
||||||
|
})(),
|
||||||
|
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
|
||||||
|
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', true),
|
||||||
|
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),
|
enableReflection: cfg.get<boolean>('enableReflection', true),
|
||||||
autoLessonFromReflection: cfg.get<boolean>('autoLessonFromReflection', true),
|
autoLessonFromReflection: cfg.get<boolean>('autoLessonFromReflection', true),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -659,6 +659,11 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.window.showErrorMessage(`Sessions 폴더 열기 실패: ${e?.message ?? e}`);
|
vscode.window.showErrorMessage(`Sessions 폴더 열기 실패: ${e?.message ?? e}`);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
vscode.commands.registerCommand('g1nation.company.pixelOffice.open', () => {
|
||||||
|
// 사이드바 mini 패널과 별도로 editor area에 전체 사무실 뷰를 띄움.
|
||||||
|
// 같은 pixelOfficeUpdate 메시지 스트림을 공유하므로 백엔드 변경 최소.
|
||||||
|
provider?.openPixelOfficePanel();
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
|
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
|
||||||
|
|||||||
@@ -227,12 +227,15 @@ export async function runCeoPlanner(
|
|||||||
ai: IAIService,
|
ai: IAIService,
|
||||||
userPrompt: string,
|
userPrompt: string,
|
||||||
state: CompanyState,
|
state: CompanyState,
|
||||||
options: { model?: string; timeoutMs?: number } = {},
|
options: { model?: string; timeoutMs?: number; contractBlock?: string } = {},
|
||||||
): Promise<PlannerResult> {
|
): Promise<PlannerResult> {
|
||||||
const system = buildPlannerSystemPrompt(
|
const baseSystem = applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName });
|
||||||
applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName }),
|
// Contract가 있으면 planner 시스템 프롬프트 끝에 prepend. planner는 task
|
||||||
state,
|
// 리스트를 JSON으로 뽑으므로 contract를 보고 *적절한* task만 만들 수 있다.
|
||||||
);
|
const systemWithContract = options.contractBlock && options.contractBlock.trim()
|
||||||
|
? `${baseSystem}\n\n${options.contractBlock.trim()}\n\n위 contract가 모든 dispatch 결정의 ground truth입니다.`
|
||||||
|
: baseSystem;
|
||||||
|
const system = buildPlannerSystemPrompt(systemWithContract, state);
|
||||||
let raw = '';
|
let raw = '';
|
||||||
try {
|
try {
|
||||||
const result = await ai.chat({
|
const result = await ai.chat({
|
||||||
|
|||||||
@@ -112,21 +112,40 @@ function _normalizeStage(raw: unknown): PipelineStage | null {
|
|||||||
const r = raw as Record<string, unknown>;
|
const r = raw as Record<string, unknown>;
|
||||||
const id = typeof r.id === 'string' ? r.id.trim() : '';
|
const id = typeof r.id === 'string' ? r.id.trim() : '';
|
||||||
const agentId = typeof r.agentId === 'string' ? r.agentId.trim() : '';
|
const agentId = typeof r.agentId === 'string' ? r.agentId.trim() : '';
|
||||||
|
const roleCategory = typeof r.roleCategory === 'string'
|
||||||
|
&& VALID_ROLE_CATEGORIES.has(r.roleCategory as AgentRoleCategory)
|
||||||
|
? (r.roleCategory as string)
|
||||||
|
: '';
|
||||||
const label = typeof r.label === 'string' && r.label.trim() ? r.label.trim() : id;
|
const label = typeof r.label === 'string' && r.label.trim() ? r.label.trim() : id;
|
||||||
if (!_validId(id) || !agentId) return null;
|
if (!_validId(id)) return null;
|
||||||
|
// agentId 또는 roleCategory 둘 중 하나는 반드시 있어야 한다.
|
||||||
|
// 둘 다 없으면 dispatcher가 누구를 부를지 알 길이 없어 stage가 의미 없음.
|
||||||
|
if (!agentId && !roleCategory) return null;
|
||||||
const out: PipelineStage = {
|
const out: PipelineStage = {
|
||||||
id, label, agentId,
|
id, label,
|
||||||
instructionTemplate: typeof r.instructionTemplate === 'string' ? r.instructionTemplate : '',
|
instructionTemplate: typeof r.instructionTemplate === 'string' ? r.instructionTemplate : '',
|
||||||
};
|
};
|
||||||
if (typeof r.roleCategory === 'string' && VALID_ROLE_CATEGORIES.has(r.roleCategory as AgentRoleCategory)) {
|
if (agentId) out.agentId = agentId;
|
||||||
out.roleCategory = r.roleCategory;
|
if (roleCategory) out.roleCategory = roleCategory;
|
||||||
}
|
|
||||||
if (typeof r.modelOverride === 'string' && r.modelOverride.trim()) {
|
if (typeof r.modelOverride === 'string' && r.modelOverride.trim()) {
|
||||||
out.modelOverride = r.modelOverride.trim();
|
out.modelOverride = r.modelOverride.trim();
|
||||||
}
|
}
|
||||||
if (r.requiresApproval === true) {
|
if (r.requiresApproval === true) {
|
||||||
out.requiresApproval = true;
|
out.requiresApproval = true;
|
||||||
}
|
}
|
||||||
|
if (typeof r.reviewWith === 'string' && r.reviewWith.trim()) {
|
||||||
|
// 'inspector' / 'role:<cat>' / 'agent:<id>' 형태만 허용. 그 외는 무시.
|
||||||
|
const rv = r.reviewWith.trim();
|
||||||
|
const isInspectorShort = rv === 'inspector';
|
||||||
|
const isRolePrefix = rv.startsWith('role:') && VALID_ROLE_CATEGORIES.has(rv.slice(5) as AgentRoleCategory);
|
||||||
|
const isAgentPrefix = rv.startsWith('agent:') && _validId(rv.slice(6));
|
||||||
|
if (isInspectorShort || isRolePrefix || isAgentPrefix) {
|
||||||
|
out.reviewWith = rv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof r.reviewMaxRounds === 'number' && Number.isFinite(r.reviewMaxRounds)) {
|
||||||
|
out.reviewMaxRounds = Math.max(1, Math.min(10, Math.round(r.reviewMaxRounds)));
|
||||||
|
}
|
||||||
if (typeof r.loopBackPattern === 'string' && r.loopBackPattern.trim()) {
|
if (typeof r.loopBackPattern === 'string' && r.loopBackPattern.trim()) {
|
||||||
out.loopBackPattern = r.loopBackPattern.trim();
|
out.loopBackPattern = r.loopBackPattern.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
buildKnowledgeMixPolicy,
|
buildKnowledgeMixPolicy,
|
||||||
} from '../../retrieval/knowledgeMix';
|
} from '../../retrieval/knowledgeMix';
|
||||||
import {
|
import {
|
||||||
|
listActiveAgentsByCategory,
|
||||||
modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix,
|
modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix,
|
||||||
} from './companyConfig';
|
} from './companyConfig';
|
||||||
import { runCeoPlanner } from './ceoPlanner';
|
import { runCeoPlanner } from './ceoPlanner';
|
||||||
@@ -64,7 +65,11 @@ import {
|
|||||||
writeResumeState,
|
writeResumeState,
|
||||||
} from './resumeStore';
|
} from './resumeStore';
|
||||||
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
|
import { buildTelegramReporter, formatCompanyTelegramReport } from './telegramReport';
|
||||||
import { AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan, PipelineDef, PipelineStage, SessionResult } from './types';
|
import {
|
||||||
|
AgentRoleCategory, AgentTurnOutput, CompanyResumeState, CompanyState, CompanyTaskPlan,
|
||||||
|
PipelineDef, PipelineStage, RequirementContract, ROLE_CATEGORY_LABELS, SessionResult,
|
||||||
|
} from './types';
|
||||||
|
import { formatContractForPrompt } from './intentAlignment';
|
||||||
|
|
||||||
/** Trim length applied when an agent's output is fed into the next agent. */
|
/** Trim length applied when an agent's output is fed into the next agent. */
|
||||||
const PEER_OUTPUT_BUDGET = 1500;
|
const PEER_OUTPUT_BUDGET = 1500;
|
||||||
@@ -105,6 +110,28 @@ export type CompanyTurnEvent =
|
|||||||
| { phase: 'awaiting-approval'; stageId: string; stageLabel: string; index: number; total: number }
|
| { phase: 'awaiting-approval'; stageId: string; stageLabel: string; index: number; total: number }
|
||||||
/** Resolved approval — purely informational for the chat log. */
|
/** Resolved approval — purely informational for the chat log. */
|
||||||
| { phase: 'approval-resolved'; stageId: string; decision: 'approve' | 'revise' | 'abort' }
|
| { phase: 'approval-resolved'; stageId: string; decision: 'approve' | 'revise' | 'abort' }
|
||||||
|
/**
|
||||||
|
* 3-way 검수 사이클 시작 — 작업자 산출물 직후, 검수자/CEO 메타-판단을
|
||||||
|
* 돌리기 직전에 emit. webview는 stage 카드 안에 라운드 누적 영역을 연다.
|
||||||
|
*/
|
||||||
|
| { phase: 'review-start'; stageId: string; stageLabel: string; maxRounds: number; inspectorAgentId: string }
|
||||||
|
/**
|
||||||
|
* 한 검수 라운드 결과. inspectorVerdict + ceoVerdict + 각자 코멘트를
|
||||||
|
* 묶어 한 이벤트로. 라운드를 chat에서 한 줄씩 누적 표시 가능하다.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
phase: 'review-round';
|
||||||
|
stageId: string;
|
||||||
|
round: number;
|
||||||
|
inspectorAgentId: string;
|
||||||
|
inspectorText: string;
|
||||||
|
inspectorVerdict: 'pass' | 'revise' | 'unclear';
|
||||||
|
ceoText: string;
|
||||||
|
ceoVerdict: 'pass' | 'revise' | 'abort' | 'unclear';
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
/** 검수 사이클 종료. final = 마지막 라운드 verdict. */
|
||||||
|
| { phase: 'review-end'; stageId: string; final: 'pass' | 'aborted' | 'maxed-out'; rounds: number }
|
||||||
| { phase: 'report-start' }
|
| { phase: 'report-start' }
|
||||||
| { phase: 'report-done'; report: string; ok: boolean }
|
| { phase: 'report-done'; report: string; ok: boolean }
|
||||||
/**
|
/**
|
||||||
@@ -160,6 +187,22 @@ export interface DispatcherDeps {
|
|||||||
* (so the dispatcher doesn't hang forever)
|
* (so the dispatcher doesn't hang forever)
|
||||||
*/
|
*/
|
||||||
awaitApproval?: (ctx: { stageId: string; stageLabel: string }) => Promise<ApprovalDecision>;
|
awaitApproval?: (ctx: { stageId: string; stageLabel: string }) => Promise<ApprovalDecision>;
|
||||||
|
/**
|
||||||
|
* 이번 turn 한정으로 활성 파이프라인을 *override*. 비어 있으면 평소대로
|
||||||
|
* `state.activePipelineId` 따른다. 의도 분류기의 `suggestedPipelineId` 또는
|
||||||
|
* 사용자 키워드(`[파이프라인:id]`) 검출 시 chatHandlers가 채워서 넘긴다.
|
||||||
|
* 알 수 없는 id면 dispatcher가 silent fallback해서 legacy 동작
|
||||||
|
* (state.activePipelineId 또는 CEO planner)로 진행.
|
||||||
|
*/
|
||||||
|
pipelineIdOverride?: string;
|
||||||
|
/**
|
||||||
|
* Intent Alignment 단계에서 사용자와 합의된 Requirement Contract. 있으면
|
||||||
|
* CEO planner / specialist prompt / 검수자(inspector + CEO) prompt 전부에
|
||||||
|
* 같은 ground truth로 주입되어 에이전트들이 추측 대신 contract를 따른다.
|
||||||
|
* 없으면 legacy 동작 — alignment 단계를 거치지 않았거나 사용자 모드가
|
||||||
|
* 'off'였던 경우.
|
||||||
|
*/
|
||||||
|
requirementContract?: RequirementContract;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -267,18 +310,34 @@ export async function runCompanyTurn(
|
|||||||
emit({ phase: 'plan-ready', plan, parsed: true, raw: '' });
|
emit({ phase: 'plan-ready', plan, parsed: true, raw: '' });
|
||||||
} else {
|
} else {
|
||||||
emit({ phase: 'plan-start' });
|
emit({ phase: 'plan-start' });
|
||||||
pipeline = resolveActivePipeline(state);
|
// deps.pipelineIdOverride가 들어왔으면 *이번 turn만* 그 파이프라인을 쓴다.
|
||||||
|
// state.activePipelineId는 건드리지 않으므로 다음 라운드부턴 다시 사용자
|
||||||
|
// 설정 따른다. override id가 유효한 파이프라인을 못 가리키면 silent fallback.
|
||||||
|
const overrideId = deps.pipelineIdOverride;
|
||||||
|
pipeline = overrideId
|
||||||
|
? (state.pipelines?.[overrideId] ?? resolveActivePipeline(state))
|
||||||
|
: resolveActivePipeline(state);
|
||||||
if (pipeline) {
|
if (pipeline) {
|
||||||
// Pipeline mode: the user has authored a fixed sequence of stages.
|
// Pipeline mode: the user has authored a fixed sequence of stages.
|
||||||
// We still surface a `plan` for the report writer and the session
|
// We still surface a `plan` for the report writer and the session
|
||||||
// summary — derived directly from the pipeline definition.
|
// summary — derived directly from the pipeline definition.
|
||||||
plan = {
|
plan = {
|
||||||
brief: `[Pipeline: ${pipeline.name}] ${userPrompt.slice(0, 200)}`,
|
brief: `[Pipeline: ${pipeline.name}] ${userPrompt.slice(0, 200)}`,
|
||||||
tasks: pipeline.stages.map((s) => ({ agent: s.agentId, task: s.label })),
|
// stage.agentId가 비어 있는 경우(CEO 동적 선택) 직군 라벨을 placeholder로
|
||||||
|
// 표시 — plan은 사전 요약용이므로 실제 dispatch는 _runPipeline에서 결정.
|
||||||
|
tasks: pipeline.stages.map((s) => ({
|
||||||
|
agent: s.agentId || (s.roleCategory ? `[직군:${s.roleCategory}]` : '[미정]'),
|
||||||
|
task: s.label,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
||||||
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, { model: ceoModel });
|
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, {
|
||||||
|
model: ceoModel,
|
||||||
|
contractBlock: deps.requirementContract
|
||||||
|
? formatContractForPrompt(deps.requirementContract)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
plan = plannerResult.plan;
|
plan = plannerResult.plan;
|
||||||
plannerRaw = plannerResult.raw;
|
plannerRaw = plannerResult.raw;
|
||||||
plannerParsed = plannerResult.parsed;
|
plannerParsed = plannerResult.parsed;
|
||||||
@@ -568,6 +627,11 @@ async function _dispatchOne(
|
|||||||
peerOutputs,
|
peerOutputs,
|
||||||
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
|
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
|
||||||
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
|
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
|
||||||
|
// alignment 단계에서 도출된 contract가 deps에 있으면 모든 specialist의
|
||||||
|
// system 프롬프트에 같은 ground truth로 prepend된다. 추측 방지.
|
||||||
|
contractBlock: deps.requirementContract
|
||||||
|
? formatContractForPrompt(deps.requirementContract)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
// 우선순위: stage > agent > global default.
|
// 우선순위: stage > agent > global default.
|
||||||
const model = (stageModelOverride && stageModelOverride.trim())
|
const model = (stageModelOverride && stageModelOverride.trim())
|
||||||
@@ -580,7 +644,62 @@ async function _dispatchOne(
|
|||||||
user: task,
|
user: task,
|
||||||
model,
|
model,
|
||||||
});
|
});
|
||||||
const rawResponse = (result.content || '').trim();
|
let rawResponse = (result.content || '').trim();
|
||||||
|
|
||||||
|
// ── Self-Reflector Phase B — 외부 검증 + 1회 retry ──
|
||||||
|
// 사용자가 selfReflector.externalVerification 켰을 때만 동작. 검증 LLM이
|
||||||
|
// 'fail' 내면 issue를 task에 prepend해서 같은 specialist 1회 더 호출.
|
||||||
|
// 검증 자체가 실패하면(verifierError) 원본 응답을 그대로 보존하고 진행 — 안전망.
|
||||||
|
let verifierIssues: string[] = [];
|
||||||
|
let verifierSummary = '';
|
||||||
|
try {
|
||||||
|
// dynamic import — Phase B는 옵션이므로 미사용 시 모듈 자체를 안 로드.
|
||||||
|
const { getConfig } = await import('../../config');
|
||||||
|
const cfgRuntime = getConfig();
|
||||||
|
if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
|
||||||
|
const { verifyResponse, formatIssuesForRetry } =
|
||||||
|
await import('../selfReflector/selfReflectorVerifier');
|
||||||
|
const { formatContractForPrompt } = await import('./intentAlignment');
|
||||||
|
const contractBlock = deps.requirementContract
|
||||||
|
? formatContractForPrompt(deps.requirementContract)
|
||||||
|
: undefined;
|
||||||
|
const verdict = await verifyResponse(deps.ai, {
|
||||||
|
task,
|
||||||
|
response: rawResponse,
|
||||||
|
agentName: def.name,
|
||||||
|
model,
|
||||||
|
contractBlock,
|
||||||
|
});
|
||||||
|
verifierIssues = verdict.issues;
|
||||||
|
verifierSummary = verdict.summary;
|
||||||
|
logInfo('selfReflector.B: verdict.', {
|
||||||
|
agentId, verdict: verdict.verdict, issuesCount: verdict.issues.length,
|
||||||
|
});
|
||||||
|
if (verdict.verdict === 'fail' && verdict.issues.length > 0) {
|
||||||
|
const retryTask = `${formatIssuesForRetry(verdict.issues)}\n\n[원래 지시]\n${task}`;
|
||||||
|
try {
|
||||||
|
const retryRes = await deps.ai.chat({
|
||||||
|
system, user: retryTask, model,
|
||||||
|
});
|
||||||
|
const retried = (retryRes.content || '').trim();
|
||||||
|
if (retried) {
|
||||||
|
rawResponse = retried;
|
||||||
|
verifierSummary = `검증 fail → 1회 retry 적용 (${verdict.issues.length}개 지적 반영)`;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('selfReflector.B: retry call failed; keeping original.', {
|
||||||
|
agentId, error: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// Phase B 전체가 실패해도 dispatch 자체는 계속.
|
||||||
|
logError('selfReflector.B: hook failed; continuing without verification.', {
|
||||||
|
agentId, error: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Apply ConnectAI's action-tag executor so `<create_file>`,
|
// Apply ConnectAI's action-tag executor so `<create_file>`,
|
||||||
// `<run_command>`, `<edit_file>`, etc. emitted by the agent actually
|
// `<run_command>`, `<edit_file>`, etc. emitted by the agent actually
|
||||||
// hit disk / shell. The report (e.g. "✅ Created: foo.py") is
|
// hit disk / shell. The report (e.g. "✅ Created: foo.py") is
|
||||||
@@ -592,8 +711,93 @@ async function _dispatchOne(
|
|||||||
try {
|
try {
|
||||||
const report = await deps.executeActionTags(rawResponse);
|
const report = await deps.executeActionTags(rawResponse);
|
||||||
actionReport = report;
|
actionReport = report;
|
||||||
if (report.length > 0) {
|
|
||||||
finalResponse = `${rawResponse}\n\n---\n**Action 실행 결과:**\n${report.map((r) => `- ${r}`).join('\n')}`;
|
// ── Self-Reflector Phase C — 생성/편집된 파일 syntax 체크 ──
|
||||||
|
// 사용자가 selfReflector.executionVerification 켰을 때만. 추가
|
||||||
|
// report 항목들을 actionReport에 append + finalResponse 첨부 본문에도 반영.
|
||||||
|
try {
|
||||||
|
const { getConfig } = await import('../../config');
|
||||||
|
const cfgRuntime = getConfig();
|
||||||
|
if (cfgRuntime.selfReflectorExecutionEnabled && actionReport.length > 0) {
|
||||||
|
const { verifyCreatedFiles } = await import('../selfReflector/selfReflectorExecution');
|
||||||
|
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||||
|
if (projectRoot) {
|
||||||
|
const extra = await verifyCreatedFiles(actionReport, projectRoot);
|
||||||
|
if (extra.length > 0) {
|
||||||
|
actionReport = [...actionReport, ...extra];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('selfReflector.C: hook failed; continuing without execution check.', {
|
||||||
|
agentId, error: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Self-Reflector Hollow Code Check (휴리스틱, LLM 콜 0) ──
|
||||||
|
// Phase C(syntax)가 잡지 못하는 *빈 깡통* 패턴을 정규식으로 잡는다.
|
||||||
|
// hollow 발견 → 1) actionReport에 ❌ 라인 추가 2) verifierIssues에
|
||||||
|
// 합류시켜 Phase B retry 트리거 (혹은 Phase B OFF면 사용자에게
|
||||||
|
// 경고만 표시). 작은 LLM이 가장 자주 만드는 실패 패턴이라
|
||||||
|
// selfReflectorEnabled가 켜져 있으면 *조건부 자동 활성화*.
|
||||||
|
try {
|
||||||
|
const { getConfig } = await import('../../config');
|
||||||
|
const cfgRuntime = getConfig();
|
||||||
|
if (cfgRuntime.selfReflectorEnabled && actionReport.length > 0) {
|
||||||
|
const { verifyHollow } = await import('../selfReflector/selfReflectorHollow');
|
||||||
|
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||||
|
if (projectRoot) {
|
||||||
|
const hollowRes = verifyHollow(actionReport, projectRoot);
|
||||||
|
if (hollowRes.hasHollow) {
|
||||||
|
actionReport = [...actionReport, ...hollowRes.extraLines];
|
||||||
|
// verifier가 켜져 있고 아직 retry 안 했다면 hollow를 issue로
|
||||||
|
// 격상해서 자동 재작업 트리거. 켜져 있지 않으면 사용자에게
|
||||||
|
// 경고만 노출(이미 actionReport에 들어감).
|
||||||
|
if (cfgRuntime.selfReflectorExternalEnabled && verifierIssues.length === 0) {
|
||||||
|
verifierIssues = hollowRes.hollowReasons.map((r) => `빈 깡통: ${r}`);
|
||||||
|
verifierSummary = `Hollow code 감지 — 자동 재시도 트리거`;
|
||||||
|
// 같은 specialist 1회 retry: 빈 깡통 지적을 task 앞에 prepend.
|
||||||
|
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 retried = (retryRes.content || '').trim();
|
||||||
|
if (retried) {
|
||||||
|
// 재작업 결과로 본문 갱신 + action-tag 다시 실행.
|
||||||
|
rawResponse = retried;
|
||||||
|
if (deps.executeActionTags && _hasActionTag(retried)) {
|
||||||
|
const retryReport = await deps.executeActionTags(retried);
|
||||||
|
actionReport = retryReport;
|
||||||
|
// 재작업 결과도 hollow 한 번 더 검사.
|
||||||
|
const reCheck = verifyHollow(retryReport, projectRoot);
|
||||||
|
if (reCheck.hasHollow) {
|
||||||
|
actionReport = [...actionReport, ...reCheck.extraLines];
|
||||||
|
verifierSummary = `재작업 후에도 hollow 일부 잔존 — 사용자 확인 필요`;
|
||||||
|
} else {
|
||||||
|
verifierSummary = `Hollow 감지 → 재작업으로 해결`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('selfReflector.hollow: retry call failed.', {
|
||||||
|
agentId, error: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!cfgRuntime.selfReflectorExternalEnabled) {
|
||||||
|
// verifier OFF — 사용자에게 경고만.
|
||||||
|
verifierSummary = `⚠️ Hollow code 감지 — externalVerification 켜면 자동 재시도`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('selfReflector.hollow: check failed; continuing.', {
|
||||||
|
agentId, error: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionReport.length > 0) {
|
||||||
|
finalResponse = `${rawResponse}\n\n---\n**Action 실행 결과:**\n${actionReport.map((r) => `- ${r}`).join('\n')}`;
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Surface the failure but keep the agent's text — partial
|
// Surface the failure but keep the agent's text — partial
|
||||||
@@ -619,6 +823,14 @@ async function _dispatchOne(
|
|||||||
// mark it as not-fully-successful so the CEO synthesis can read
|
// mark it as not-fully-successful so the CEO synthesis can read
|
||||||
// the warning verbatim.
|
// the warning verbatim.
|
||||||
const claimedButDidnt = rawResponse && !hasTag && _claimsFileCreation(rawResponse);
|
const claimedButDidnt = rawResponse && !hasTag && _claimsFileCreation(rawResponse);
|
||||||
|
// 검증 요약을 response 끝에 한 줄로 첨부 — 사용자가 *어떻게 검증됐는지*
|
||||||
|
// 빠르게 보고 신뢰도 가늠. issues가 있으면 같이 노출.
|
||||||
|
if (verifierSummary) {
|
||||||
|
const issuesText = verifierIssues.length > 0
|
||||||
|
? '\n' + verifierIssues.map((i) => ` - ${i}`).join('\n')
|
||||||
|
: '';
|
||||||
|
finalResponse = `${finalResponse}\n\n---\n**🔬 외부 검증:** ${verifierSummary}${issuesText}`;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
agentId, task,
|
agentId, task,
|
||||||
response: finalResponse,
|
response: finalResponse,
|
||||||
@@ -663,6 +875,282 @@ interface PipelineSeed {
|
|||||||
startIndex: number;
|
startIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which agent should run a given stage *right now*.
|
||||||
|
*
|
||||||
|
* Priority order:
|
||||||
|
* 1. `stage.agentId` is explicitly set → use that agent verbatim. The
|
||||||
|
* user pinned this stage to a specific person; honour it.
|
||||||
|
* 2. No agentId but `stage.roleCategory` → pull the active agents in
|
||||||
|
* that category. If exactly one is active, use them (saves an LLM
|
||||||
|
* call on the common case). If multiple, ask CEO via a single short
|
||||||
|
* JSON-shaped LLM call which is best fit for this *specific task*.
|
||||||
|
* 3. Neither — return null so the dispatcher can record an error and
|
||||||
|
* skip the stage cleanly. (normalize already rejects this case but
|
||||||
|
* we guard at runtime in case a stale state slipped through.)
|
||||||
|
*
|
||||||
|
* The LLM call is wrapped in try/catch with a `firstCandidate` fallback:
|
||||||
|
* a bad classifier response should never block the pipeline, just degrade
|
||||||
|
* to "first active agent in role". Caller decides whether to surface a
|
||||||
|
* note about who CEO chose; we just return `{ agentId, source, reason? }`.
|
||||||
|
*/
|
||||||
|
async function _resolveStageAgent(
|
||||||
|
stage: PipelineStage,
|
||||||
|
taskText: string,
|
||||||
|
state: CompanyState,
|
||||||
|
deps: DispatcherDeps,
|
||||||
|
): Promise<{ agentId: string; source: 'pinned' | 'sole-candidate' | 'ceo-selected' | 'fallback-first'; reason?: string } | null> {
|
||||||
|
if (stage.agentId && resolveAgent(state, stage.agentId)) {
|
||||||
|
return { agentId: stage.agentId, source: 'pinned' };
|
||||||
|
}
|
||||||
|
const cat = stage.roleCategory as AgentRoleCategory | undefined;
|
||||||
|
if (!cat) return null;
|
||||||
|
const candidates = listActiveAgentsByCategory(state)[cat] ?? [];
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
return { agentId: candidates[0].id, source: 'sole-candidate' };
|
||||||
|
}
|
||||||
|
// 다수 후보 → CEO에게 1회 LLM 콜로 결정. 시스템 프롬프트는 짧게, JSON만.
|
||||||
|
const catLabel = ROLE_CATEGORY_LABELS[cat] ?? cat;
|
||||||
|
const optionsBlock = candidates.map((c) =>
|
||||||
|
`- id: ${c.id} | 이름: ${c.name} ${c.emoji}`).join('\n');
|
||||||
|
const system = `당신은 1인 기업의 CEO입니다. 다음 task에 가장 적합한 *${catLabel}* 직군 구성원 한 명을 골라주세요.\n\n반드시 아래 JSON 한 줄만 출력. 다른 텍스트(설명, 펜스, 머리말) 일체 금지.\n{"agentId":"<선택한 id>","reason":"한 줄(40자 이내)"}`;
|
||||||
|
const user = `[현재 stage] ${stage.label || stage.id}\n[task]\n${taskText.slice(0, 600)}\n\n[후보]\n${optionsBlock}\n\n위 후보 중 task에 가장 적합한 한 명을 id로 골라 JSON 응답:`;
|
||||||
|
try {
|
||||||
|
const result = await deps.ai.chat({
|
||||||
|
system, user,
|
||||||
|
model: modelForAgent(state, 'ceo', deps.defaultModel),
|
||||||
|
});
|
||||||
|
const raw = (result.content || '').trim();
|
||||||
|
// 가벼운 파서 — 코드펜스 / 잡문 제거 후 첫 {…} 추출.
|
||||||
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||||
|
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||||
|
let picked: { agentId?: unknown; reason?: unknown } | null = null;
|
||||||
|
try { picked = JSON.parse(stage1); } catch {
|
||||||
|
const m = stage1.match(/\{[\s\S]*\}/);
|
||||||
|
if (m) { try { picked = JSON.parse(m[0]); } catch { /* fall through */ } }
|
||||||
|
}
|
||||||
|
const aid = typeof picked?.agentId === 'string' ? picked.agentId.trim() : '';
|
||||||
|
if (aid && candidates.some((c) => c.id === aid)) {
|
||||||
|
const reason = typeof picked?.reason === 'string' ? picked.reason.trim() : '';
|
||||||
|
return { agentId: aid, source: 'ceo-selected', reason };
|
||||||
|
}
|
||||||
|
// 응답이 유효한 후보가 아님 → 첫 번째로 폴백.
|
||||||
|
logInfo('dispatcher: CEO selection invalid; falling back to first candidate.', {
|
||||||
|
stageId: stage.id, rawHead: raw.slice(0, 80),
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('dispatcher: CEO selection call failed; falling back.', {
|
||||||
|
stageId: stage.id, error: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { agentId: candidates[0].id, source: 'fallback-first' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검수자(또는 직군)를 stage.reviewWith 값에 따라 한 명 결정.
|
||||||
|
* - 'inspector' / 'role:<cat>' → 해당 직군 활성 후보 중 첫 번째
|
||||||
|
* - 'agent:<id>' → 그 에이전트 (활성/비활성 무관)
|
||||||
|
* 후보가 없으면 null — 호출자가 검수 사이클을 skip.
|
||||||
|
*/
|
||||||
|
function _resolveInspector(
|
||||||
|
reviewWith: string,
|
||||||
|
state: CompanyState,
|
||||||
|
): { agentId: string } | null {
|
||||||
|
if (reviewWith === 'inspector') {
|
||||||
|
const list = listActiveAgentsByCategory(state)['inspector'] ?? [];
|
||||||
|
return list[0] ? { agentId: list[0].id } : null;
|
||||||
|
}
|
||||||
|
if (reviewWith.startsWith('role:')) {
|
||||||
|
const cat = reviewWith.slice(5) as AgentRoleCategory;
|
||||||
|
const list = listActiveAgentsByCategory(state)[cat] ?? [];
|
||||||
|
return list[0] ? { agentId: list[0].id } : null;
|
||||||
|
}
|
||||||
|
if (reviewWith.startsWith('agent:')) {
|
||||||
|
const id = reviewWith.slice(6);
|
||||||
|
return resolveAgent(state, id) ? { agentId: id } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검수자 응답의 첫 줄에서 verdict를 끌어낸다. 작은 모델이 라벨 흐트러뜨릴 수
|
||||||
|
* 있어 키워드 매칭으로 관대하게. 못 잡으면 'unclear' — 호출자가 안전한 쪽
|
||||||
|
* (보통 'revise')으로 폴백.
|
||||||
|
*/
|
||||||
|
function _parseInspectorVerdict(text: string): 'pass' | 'revise' | 'unclear' {
|
||||||
|
const head = (text || '').split(/\n/, 1)[0] ?? '';
|
||||||
|
if (/^\s*(?:✅|통과|승인|pass|approve|ok)/i.test(head)) return 'pass';
|
||||||
|
if (/^\s*(?:❌|보완|재작업|revise|reject|fail|보완 필요)/i.test(head)) return 'revise';
|
||||||
|
// 본문에 명확한 신호가 있으면 잡아냄 — 작은 모델이 머리말을 빠뜨리는 경우.
|
||||||
|
if (/✅\s*통과|모든 케이스 통과/.test(text)) return 'pass';
|
||||||
|
if (/❌|보완 필요|재작업/.test(text)) return 'revise';
|
||||||
|
return 'unclear';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseCeoVerdict(text: string): 'pass' | 'revise' | 'abort' | 'unclear' {
|
||||||
|
const head = (text || '').split(/\n/, 1)[0] ?? '';
|
||||||
|
if (/^\s*(?:✅|통과|approve|pass|최종\s*ok|진행)/i.test(head)) return 'pass';
|
||||||
|
if (/^\s*(?:🔁|보완|한 번 더|revise|다시)/i.test(head)) return 'revise';
|
||||||
|
if (/^\s*(?:🛑|중단|stop|abort|그만)/i.test(head)) return 'abort';
|
||||||
|
if (/✅\s*통과/.test(text)) return 'pass';
|
||||||
|
if (/🛑|중단/.test(text)) return 'abort';
|
||||||
|
if (/🔁|보완|한 번 더/.test(text)) return 'revise';
|
||||||
|
return 'unclear';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3-way 합의 검수 사이클. 작업자 산출물(latestOutput)을 받고:
|
||||||
|
* 1. 검수자에게 보내 ✅/❌ 코멘트를 받음
|
||||||
|
* 2. CEO에게 (산출물 + 검수자 코멘트)를 보내 ✅/🔁/🛑 메타-판단을 받음
|
||||||
|
* 3. 검수자 ✅ + CEO ✅ → pass / 아니면 다음 라운드 / CEO 🛑 → 즉시 abort
|
||||||
|
* 4. 최대 라운드 도달 시 maxed-out (강제 통과로 처리하되 webview에 경고)
|
||||||
|
*
|
||||||
|
* Revise verdict 시 작업자에게 *어떤 부분을 고쳐야 하는지* 검수자 코멘트가
|
||||||
|
* 그대로 전달돼야 하므로 revisionNotes 맵에 검수 코멘트를 채워 caller가
|
||||||
|
* 사용자 코멘트와 동일한 메커니즘으로 stage 재실행하게 한다.
|
||||||
|
*/
|
||||||
|
async function _runReviewCycle(args: {
|
||||||
|
stage: PipelineStage;
|
||||||
|
stageTaskText: string;
|
||||||
|
latestOutput: AgentTurnOutput;
|
||||||
|
state: CompanyState;
|
||||||
|
deps: DispatcherDeps;
|
||||||
|
emit: CompanyTurnEmitter;
|
||||||
|
isAborted: () => boolean;
|
||||||
|
}): Promise<{
|
||||||
|
verdict: 'pass' | 'revise' | 'abort' | 'maxed-out' | 'aborted';
|
||||||
|
revisionNote?: string;
|
||||||
|
rounds: number;
|
||||||
|
}> {
|
||||||
|
const { stage, stageTaskText, latestOutput, state, deps, emit, isAborted } = args;
|
||||||
|
const reviewWith = stage.reviewWith || '';
|
||||||
|
if (!reviewWith) return { verdict: 'pass', rounds: 0 };
|
||||||
|
const inspector = _resolveInspector(reviewWith, state);
|
||||||
|
if (!inspector) {
|
||||||
|
// 검수자 못 찾으면 사이클 생략하고 통과로 처리 — 사용자에게 보이지
|
||||||
|
// 않게 silent; 카드 에디터의 검수 dropdown에서 사용자가 직접 인지할
|
||||||
|
// 수 있다.
|
||||||
|
logInfo('reviewCycle: no inspector resolvable; skipping.', { stageId: stage.id, reviewWith });
|
||||||
|
return { verdict: 'pass', rounds: 0 };
|
||||||
|
}
|
||||||
|
const maxRounds = Math.max(1, Math.min(10, stage.reviewMaxRounds ?? 3));
|
||||||
|
emit({
|
||||||
|
phase: 'review-start',
|
||||||
|
stageId: stage.id,
|
||||||
|
stageLabel: stage.label || stage.id,
|
||||||
|
maxRounds,
|
||||||
|
inspectorAgentId: inspector.agentId,
|
||||||
|
});
|
||||||
|
let currentOutput = latestOutput;
|
||||||
|
let lastInspectorText = '';
|
||||||
|
let lastInspectorVerdict: 'pass' | 'revise' | 'unclear' = 'unclear';
|
||||||
|
let lastCeoText = '';
|
||||||
|
let lastCeoVerdict: 'pass' | 'revise' | 'abort' | 'unclear' = 'unclear';
|
||||||
|
for (let round = 1; round <= maxRounds; round++) {
|
||||||
|
if (isAborted()) {
|
||||||
|
emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round - 1 });
|
||||||
|
return { verdict: 'aborted', rounds: round - 1 };
|
||||||
|
}
|
||||||
|
const startedAt = Date.now();
|
||||||
|
// contract가 있으면 검수자/CEO 모두에게 같은 ground truth를 prepend —
|
||||||
|
// 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
|
||||||
|
const contractPrefix = deps.requirementContract
|
||||||
|
? formatContractForPrompt(deps.requirementContract) + '\n\n'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// ── 1) 검수자 LLM 콜 ──
|
||||||
|
const inspectorSystem = contractPrefix + '당신은 산출물 *감리*입니다. 작업자의 결과물을 객관적으로 검토하고 한국어 마크다운으로 응답하세요.\n\n반드시 첫 줄을 다음 둘 중 하나로 시작:\n - ✅ 통과 — 산출물이 task 요구 + 위 contract의 criteria를 모두 충족하면.\n - ❌ 보완 필요: <구체 항목 한 줄> — contract 기준 누락·오류·약점이 있으면.\n\n그 다음 줄들에 *구체적인* 피드백 또는 칭찬 1~3줄. 모호한 일반론 금지.';
|
||||||
|
const inspectorUser = `[현재 stage] ${stage.label || stage.id}\n[task]\n${stageTaskText.slice(0, 1500)}\n\n[작업자 산출물]\n${(currentOutput.response || '').slice(0, 3000)}`;
|
||||||
|
let inspectorText = '';
|
||||||
|
try {
|
||||||
|
const res = await deps.ai.chat({
|
||||||
|
system: inspectorSystem,
|
||||||
|
user: inspectorUser,
|
||||||
|
model: modelForAgent(state, inspector.agentId, deps.defaultModel),
|
||||||
|
});
|
||||||
|
inspectorText = (res.content || '').trim();
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('reviewCycle: inspector call failed.', { stageId: stage.id, round, err: e?.message ?? String(e) });
|
||||||
|
inspectorText = `❌ 보완 필요: 검수자 호출 실패 (${e?.message ?? '알 수 없음'}) — 안전을 위해 한 번 더 시도`;
|
||||||
|
}
|
||||||
|
lastInspectorText = inspectorText;
|
||||||
|
lastInspectorVerdict = _parseInspectorVerdict(inspectorText);
|
||||||
|
|
||||||
|
if (isAborted()) {
|
||||||
|
emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round });
|
||||||
|
return { verdict: 'aborted', rounds: round };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2) CEO 메타-판단 ──
|
||||||
|
const ceoSystem = contractPrefix + '당신은 회사 CEO입니다. 작업자 산출물 + 검수자 의견을 보고 *세 명이 모두 만족하는지* 메타-판단을 내립니다. 위 contract 기준에 부합하는지가 핵심.\n\n반드시 첫 줄을 다음 셋 중 하나로 시작:\n - ✅ 통과 — 산출물·검수가 contract criteria를 모두 충족.\n - 🔁 보완 — contract 기준 한 가지 이상 미흡. 작업자에게 줄 구체 지시 1~3줄.\n - 🛑 중단 — 라운드 더 돌아도 의미 없음. 사장님께 현 상태로 보고.';
|
||||||
|
const ceoUser = `[stage] ${stage.label || stage.id}\n[task]\n${stageTaskText.slice(0, 1000)}\n\n[작업자 산출물]\n${(currentOutput.response || '').slice(0, 2000)}\n\n[검수자 의견]\n${inspectorText.slice(0, 1500)}\n\n[지금 라운드: ${round}/${maxRounds}]`;
|
||||||
|
let ceoText = '';
|
||||||
|
try {
|
||||||
|
const res = await deps.ai.chat({
|
||||||
|
system: ceoSystem,
|
||||||
|
user: ceoUser,
|
||||||
|
model: modelForAgent(state, 'ceo', deps.defaultModel),
|
||||||
|
});
|
||||||
|
ceoText = (res.content || '').trim();
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('reviewCycle: CEO meta call failed.', { stageId: stage.id, round, err: e?.message ?? String(e) });
|
||||||
|
ceoText = lastInspectorVerdict === 'pass' ? '✅ 통과' : '🔁 보완';
|
||||||
|
}
|
||||||
|
lastCeoText = ceoText;
|
||||||
|
lastCeoVerdict = _parseCeoVerdict(ceoText);
|
||||||
|
|
||||||
|
emit({
|
||||||
|
phase: 'review-round',
|
||||||
|
stageId: stage.id,
|
||||||
|
round,
|
||||||
|
inspectorAgentId: inspector.agentId,
|
||||||
|
inspectorText,
|
||||||
|
inspectorVerdict: lastInspectorVerdict,
|
||||||
|
ceoText,
|
||||||
|
ceoVerdict: lastCeoVerdict,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3) 합의 판정 ──
|
||||||
|
// 검수자 ✅ + CEO ✅ → 통과. CEO 🛑 → 즉시 중단. 그 외 → 다음 라운드.
|
||||||
|
// unclear는 안전한 쪽(revise)으로 폴백.
|
||||||
|
if (lastInspectorVerdict === 'pass' && lastCeoVerdict === 'pass') {
|
||||||
|
emit({ phase: 'review-end', stageId: stage.id, final: 'pass', rounds: round });
|
||||||
|
return { verdict: 'pass', rounds: round };
|
||||||
|
}
|
||||||
|
if (lastCeoVerdict === 'abort') {
|
||||||
|
emit({ phase: 'review-end', stageId: stage.id, final: 'aborted', rounds: round });
|
||||||
|
return { verdict: 'abort', rounds: round };
|
||||||
|
}
|
||||||
|
// revise — 다음 라운드 진입 전 작업자에게 줄 코멘트 합성.
|
||||||
|
const note = [
|
||||||
|
`[검수자 ${inspector.agentId}] ${inspectorText.slice(0, 600)}`,
|
||||||
|
`[CEO 메타] ${ceoText.slice(0, 400)}`,
|
||||||
|
].join('\n\n');
|
||||||
|
// 마지막 라운드 직전이라면 더 이상 작업자를 부를 일 없음 — 그냥 maxed-out.
|
||||||
|
if (round >= maxRounds) {
|
||||||
|
emit({ phase: 'review-end', stageId: stage.id, final: 'maxed-out', rounds: round });
|
||||||
|
return { verdict: 'maxed-out', revisionNote: note, rounds: round };
|
||||||
|
}
|
||||||
|
// 작업자 재실행: caller가 stage를 다시 dispatch하도록 revisionNote 전달.
|
||||||
|
// 그런데 사이클은 한 단위(검수+CEO)를 caller 밖에서 끝나야 하므로 여기서
|
||||||
|
// 직접 작업자 재실행 → 새 currentOutput 갱신.
|
||||||
|
const reDispatchTask = `[검수 피드백 — ${round}라운드]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${stageTaskText}`;
|
||||||
|
emit({ phase: 'agent-start', agentId: currentOutput.agentId, task: reDispatchTask, index: -1, total: maxRounds });
|
||||||
|
const reTurn = await _dispatchOne(currentOutput.agentId, reDispatchTask, [], state, deps, stage.modelOverride);
|
||||||
|
emit({ phase: 'agent-done', agentId: currentOutput.agentId, output: reTurn, index: -1, total: maxRounds });
|
||||||
|
currentOutput = reTurn;
|
||||||
|
}
|
||||||
|
// 정상 흐름에선 위 break 조건 중 하나로 빠지지만 안전망으로:
|
||||||
|
emit({ phase: 'review-end', stageId: stage.id, final: 'maxed-out', rounds: maxRounds });
|
||||||
|
return {
|
||||||
|
verdict: 'maxed-out',
|
||||||
|
revisionNote: `[검수자 ${inspector.agentId}] ${lastInspectorText.slice(0, 600)}\n\n[CEO 메타] ${lastCeoText.slice(0, 400)}`,
|
||||||
|
rounds: maxRounds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** _runPipeline이 매 stage 직후 호출하는 commit 콜백의 payload. */
|
/** _runPipeline이 매 stage 직후 호출하는 commit 콜백의 payload. */
|
||||||
export interface PipelineCommit {
|
export interface PipelineCommit {
|
||||||
outputs: AgentTurnOutput[];
|
outputs: AgentTurnOutput[];
|
||||||
@@ -740,20 +1228,77 @@ async function _runPipeline(
|
|||||||
const task = note
|
const task = note
|
||||||
? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
|
? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
|
||||||
: baseTask;
|
: baseTask;
|
||||||
emit({ phase: 'agent-start', agentId: stage.agentId, task, index: stepIndex, total });
|
// 동적 담당자 해결. stage.agentId가 박혀 있으면 그걸 쓰고, 비어 있으면
|
||||||
const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps, stage.modelOverride);
|
// CEO가 직군 후보 중에서 1회 LLM 콜로 적임자 선택. 모든 후보가 비활성/없음
|
||||||
|
// 이면 null — 그 경우 stage를 에러로 마킹하고 건너뛴다(파이프라인 hang 방지).
|
||||||
|
const picked = await _resolveStageAgent(stage, task, state, deps);
|
||||||
|
if (!picked) {
|
||||||
|
const errOutput: AgentTurnOutput = {
|
||||||
|
agentId: stage.agentId || `<${stage.roleCategory ?? 'unknown'}>`,
|
||||||
|
task,
|
||||||
|
response: `⚠️ 이 단계에 배정할 활성 에이전트가 없습니다 (직군: ${stage.roleCategory ?? '미지정'}). 관리 패널에서 해당 직군의 에이전트를 활성화하거나, stage에 직접 담당자를 지정하세요.`,
|
||||||
|
durationMs: 0,
|
||||||
|
error: 'no-active-agent-in-role',
|
||||||
|
};
|
||||||
|
outputs.push(errOutput);
|
||||||
|
latestByStage[stage.id] = errOutput;
|
||||||
|
writeAgentOutput(sessionDir, errOutput);
|
||||||
|
emit({ phase: 'agent-done', agentId: errOutput.agentId, output: errOutput, index: stepIndex, total });
|
||||||
|
stepIndex++;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resolvedAgentId = picked.agentId;
|
||||||
|
// CEO 선택 시 사용자에게 *왜 이 사람*인지 한 줄로 보여주기 위해 task 앞에
|
||||||
|
// 짧은 메타 한 줄을 prepend — 에이전트 시스템 프롬프트엔 영향 없고 chat
|
||||||
|
// 카드 표시에만 쓰인다.
|
||||||
|
let taskForChat = task;
|
||||||
|
if (picked.source === 'ceo-selected' && picked.reason) {
|
||||||
|
taskForChat = `[🧭 CEO 선임: ${picked.reason}]\n\n${task}`;
|
||||||
|
}
|
||||||
|
emit({ phase: 'agent-start', agentId: resolvedAgentId, task: taskForChat, index: stepIndex, total });
|
||||||
|
const turn = await _dispatchOne(resolvedAgentId, task, outputs, state, deps, stage.modelOverride);
|
||||||
outputs.push(turn);
|
outputs.push(turn);
|
||||||
latestByStage[stage.id] = turn;
|
latestByStage[stage.id] = turn;
|
||||||
writeAgentOutput(sessionDir, turn);
|
writeAgentOutput(sessionDir, turn);
|
||||||
appendAgentMemory(
|
appendAgentMemory(
|
||||||
deps.context, stage.agentId,
|
deps.context, resolvedAgentId,
|
||||||
`[${timestamp}][${pipeline.id}/${stage.id}] ${task.slice(0, 120)} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
|
`[${timestamp}][${pipeline.id}/${stage.id}] ${task.slice(0, 120)} — ${turn.error ? `❌ ${turn.error}` : '✅'}`,
|
||||||
);
|
);
|
||||||
emit({ phase: 'agent-done', agentId: stage.agentId, output: turn, index: stepIndex, total });
|
emit({ phase: 'agent-done', agentId: resolvedAgentId, output: turn, index: stepIndex, total });
|
||||||
stepIndex++;
|
stepIndex++;
|
||||||
// Successful run consumed the revision note (if any) — clear it.
|
// Successful run consumed the revision note (if any) — clear it.
|
||||||
if (!turn.error) delete revisionNotes[stage.id];
|
if (!turn.error) delete revisionNotes[stage.id];
|
||||||
|
|
||||||
|
// ── 3-way 검수 사이클 ──
|
||||||
|
// 작업자가 에러 없이 응답을 냈고, stage에 reviewWith가 설정돼 있으면
|
||||||
|
// 검수자 + CEO 메타-판단 사이클로 합의를 도출. 합의 실패 시:
|
||||||
|
// - revise/maxed-out: 검수 코멘트를 revisionNote로 받아 stage 재실행
|
||||||
|
// (loop-back과 동일한 메커니즘 재활용)
|
||||||
|
// - abort: 사용자에게 알리고 라운드 종료
|
||||||
|
if (stage.reviewWith && !turn.error) {
|
||||||
|
const reviewResult = await _runReviewCycle({
|
||||||
|
stage,
|
||||||
|
stageTaskText: task,
|
||||||
|
latestOutput: turn,
|
||||||
|
state, deps, emit, isAborted,
|
||||||
|
});
|
||||||
|
if (reviewResult.verdict === 'aborted') {
|
||||||
|
return abortReturn('aborted-during-review');
|
||||||
|
}
|
||||||
|
if (reviewResult.verdict === 'abort') {
|
||||||
|
return abortReturn('aborted-by-ceo-review');
|
||||||
|
}
|
||||||
|
// revise / maxed-out — 모두 작업자에게 다시 보내 한 번 더 (loop-back).
|
||||||
|
// 단, maxed-out은 사용자에게 "한계 도달, 마지막 결과로 진행"을 알려야
|
||||||
|
// 더 자연스러우므로 다음 stage로 그대로 진행 (revisionNote 무시).
|
||||||
|
if (reviewResult.verdict === 'revise' && reviewResult.revisionNote) {
|
||||||
|
revisionNotes[stage.id] = reviewResult.revisionNote;
|
||||||
|
continue; // 같은 stage 재실행 — while(i)는 그대로
|
||||||
|
}
|
||||||
|
// pass / maxed-out → 다음 단계로 진행 (revisionNotes 클리어는 위에서 이미)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Manual approval gate ──
|
// ── Manual approval gate ──
|
||||||
// After agent-done emits, before loop-back / next stage advance,
|
// After agent-done emits, before loop-back / next stage advance,
|
||||||
// give the user a chance to inspect and approve. We only fire the
|
// give the user a chance to inspect and approve. We only fire the
|
||||||
|
|||||||
@@ -89,3 +89,18 @@ export {
|
|||||||
listSessions,
|
listSessions,
|
||||||
resolveCompanyBase,
|
resolveCompanyBase,
|
||||||
} from './sessionStore';
|
} from './sessionStore';
|
||||||
|
|
||||||
|
export { classifyChatIntent } from './intentClassifier';
|
||||||
|
export type { ChatIntent, IntentContext, IntentResult, PipelineHint } from './intentClassifier';
|
||||||
|
|
||||||
|
export { analyzeIntent, formatContractForPrompt } from './intentAlignment';
|
||||||
|
export type { IntentAnalysisInput, IntentAnalysisResult } from './intentAlignment';
|
||||||
|
export type { RequirementContract } from './types';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getStatusBubbleText, getEventBubbleText, eventBubbleType, makeBubble,
|
||||||
|
} from './pixelOfficeState';
|
||||||
|
export type {
|
||||||
|
AgentStatus, AgentEvent, AgentBubble, AgentWorkState,
|
||||||
|
PixelOfficeConfig, BubbleType,
|
||||||
|
} from './pixelOfficeState';
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* Intent Alignment — 사용자의 자연어 요청을 *실행 가능한 작업 조건*으로 변환.
|
||||||
|
*
|
||||||
|
* 사용자는 자기 의도와 배경지식이 에이전트에게 충분히 전달되었다고 착각하는
|
||||||
|
* 경향이 있다 (투명성의 착각·지식의 저주·공통 기반 부족). 그래서 에이전트가
|
||||||
|
* 즉시 작업에 돌입하면 사용자가 머릿속에 가진 것과 다른 결과를 만들어 낸다.
|
||||||
|
*
|
||||||
|
* 이 모듈은 그 격차를 메꾸는 한 단계 앞 절차다. 사용자가 던진 한 줄을 받아
|
||||||
|
* `RequirementContract` 5필드(C-G-C-F-Q) 로 채우고, 채우다가 비는 자리가
|
||||||
|
* 있으면 *추측하지 말고* 사용자에게 되묻는다. 분석기 자체는 LLM 한 번 호출로
|
||||||
|
* 끝난다; 추가 라운드(되묻기→답변→재분석)는 호출자(상태 머신, Phase B)가
|
||||||
|
* 관리한다.
|
||||||
|
*
|
||||||
|
* 출력 형식은 dispatcher의 다른 모듈(planner/promptBuilder/reviewer)이 모두
|
||||||
|
* 같은 ground truth로 contract를 읽어 가는 것이 목표라, 필드 이름과 의미는
|
||||||
|
* `types.ts`의 `RequirementContract`와 1:1로 맞췄다.
|
||||||
|
*/
|
||||||
|
import { IAIService } from '../../core/services';
|
||||||
|
import { logError, logInfo } from '../../utils';
|
||||||
|
import { RequirementContract } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분석 한 회차의 결과. contract는 항상 채워서 돌아오고, 추가 정보가 필요한
|
||||||
|
* 경우만 confidence가 medium/low이고 openQuestions가 비어 있지 않다. 호출자가
|
||||||
|
* 사용자에게 보여주고 답을 받아 다음 라운드의 `previousAnswers`로 넣어주면
|
||||||
|
* 같은 함수가 갱신된 contract를 반환한다.
|
||||||
|
*/
|
||||||
|
export interface IntentAnalysisResult {
|
||||||
|
contract: RequirementContract;
|
||||||
|
/** Raw LLM body — 디버그 로그 / 카드에 raw 안 보여줄 거지만 남겨 둠. */
|
||||||
|
raw: string;
|
||||||
|
/** JSON 파싱 성공 여부. false면 contract는 fallback 값(원문만 채워진 상태). */
|
||||||
|
parsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 호출자가 한 라운드의 컨텍스트로 넘기는 입력. `previousAnswers`는 직전
|
||||||
|
* 라운드에서 사용자가 답한 질문/응답 쌍이며 LLM이 그걸 반영해 contract를
|
||||||
|
* 다시 채운다. `previousContract`는 직전 분석의 결과 — 분석기는 보통 이걸
|
||||||
|
* 출발점으로 부족분만 보강한다.
|
||||||
|
*/
|
||||||
|
export interface IntentAnalysisInput {
|
||||||
|
userOriginalPrompt: string;
|
||||||
|
/** 직전 라운드의 사용자 응답들. 첫 라운드면 빈 배열. */
|
||||||
|
previousAnswers?: Array<{ q: string; a: string }>;
|
||||||
|
/** 직전 라운드 contract (있으면 부분 갱신을 유도). */
|
||||||
|
previousContract?: RequirementContract;
|
||||||
|
/** 활성 파이프라인 이름 — 분석기가 format 추정에 사용 가능. */
|
||||||
|
activePipelineName?: string;
|
||||||
|
/**
|
||||||
|
* 활성 직군 목록 — "이 회사가 어떤 일들을 할 수 있나"를 분석기가 알면
|
||||||
|
* goal/format을 그쪽 능력에 맞춰 추출할 수 있다.
|
||||||
|
*/
|
||||||
|
availableRoleCategories?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입니다. 사용자의 자연어 요청을 받아 그것을 실행 가능한 작업 조건 5가지(C-G-C-F-Q)로 정리합니다.
|
||||||
|
|
||||||
|
- context : 현재 상황·프로젝트 맥락 (한 단락 또는 빈 문자열).
|
||||||
|
- goal : 사용자가 *결과로* 달성하려는 것 (1~2 문장).
|
||||||
|
- criteria : 좋은 결과의 판단 기준들. 측정 가능하면 더 좋음. 최대 4개.
|
||||||
|
- format : 원하는 산출물의 형식 (예: "마크다운 기획서", "Python 단일 파일", "JSON + 짧은 요약").
|
||||||
|
- openQuestions : 채워지지 않아 사용자에게 *물어봐야* 할 질문들. 최대 3개. 정말 결정적인 것만.
|
||||||
|
|
||||||
|
⚠️ 추측 금지. 사용자의 한 줄 + 컨텍스트에서 *직접 추론*되지 않는 정보는 채우지 마세요. 빈 칸은 그대로 두고 그 자리에 대응하는 질문을 openQuestions에 넣으세요.
|
||||||
|
|
||||||
|
confidence는 다음 기준으로 자체 판정:
|
||||||
|
- "high" : C·G·C·F 4개 모두 prompt에서 직접 추론 가능. openQuestions = [] 가능.
|
||||||
|
- "medium" : 대체로 명확하지만 1~2개 항목에서 합리적 가정 필요. 추가 질문 1~2개.
|
||||||
|
- "low" : 핵심 정보(특히 goal 또는 format)가 빠짐. 질문 2~3개.
|
||||||
|
|
||||||
|
직전 라운드 답변이 있으면 그 내용을 반영해 contract를 *갱신*하세요. 같은 질문을 다시 묻지 마세요.
|
||||||
|
|
||||||
|
⚠️ 반드시 아래 JSON 한 번만 출력. 다른 텍스트(설명·코드펜스·머리말) 일체 금지.
|
||||||
|
|
||||||
|
{
|
||||||
|
"context": "<문자열 또는 빈값>",
|
||||||
|
"goal": "<문자열 또는 빈값>",
|
||||||
|
"criteria": ["<항목1>", "<항목2>", ...],
|
||||||
|
"format": "<문자열 또는 빈값>",
|
||||||
|
"openQuestions": ["<질문1>", "<질문2>", ...],
|
||||||
|
"confidence": "low"|"medium"|"high"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
function _buildUserMessage(input: IntentAnalysisInput): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('[사용자 원본 요청]');
|
||||||
|
lines.push(input.userOriginalPrompt);
|
||||||
|
if (input.activePipelineName) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`(활성 파이프라인) "${input.activePipelineName}"`);
|
||||||
|
}
|
||||||
|
if (input.availableRoleCategories && input.availableRoleCategories.length > 0) {
|
||||||
|
lines.push(`(이 회사 가능 직군) ${input.availableRoleCategories.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (input.previousContract) {
|
||||||
|
const c = input.previousContract;
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[직전 라운드까지 도출된 contract]');
|
||||||
|
lines.push(`context: ${c.context || '(미)'}`);
|
||||||
|
lines.push(`goal: ${c.goal || '(미)'}`);
|
||||||
|
lines.push(`criteria: ${c.criteria.length ? c.criteria.join(' | ') : '(미)'}`);
|
||||||
|
lines.push(`format: ${c.format || '(미)'}`);
|
||||||
|
}
|
||||||
|
if (input.previousAnswers && input.previousAnswers.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[사용자가 직전 라운드에 답한 내용]');
|
||||||
|
for (const qa of input.previousAnswers) {
|
||||||
|
lines.push(`- Q: ${qa.q}`);
|
||||||
|
lines.push(` A: ${qa.a}`);
|
||||||
|
}
|
||||||
|
lines.push('위 답변을 반영해 contract를 갱신하고 새 openQuestions를 적되, 이미 답을 받은 질문은 *다시 묻지 마세요*.');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('분석 JSON만 출력:');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4-stage 관용 파서. intentClassifier와 동일 패턴 — 작은 모델이 펜스/머리말
|
||||||
|
* 흔히 추가하므로 strict JSON.parse 한 번만 시도하면 절반 가까이 놓친다.
|
||||||
|
*/
|
||||||
|
function _parseAnalysisJson(raw: string): {
|
||||||
|
context: string;
|
||||||
|
goal: string;
|
||||||
|
criteria: string[];
|
||||||
|
format: string;
|
||||||
|
openQuestions: string[];
|
||||||
|
confidence: 'low' | 'medium' | 'high';
|
||||||
|
} | null {
|
||||||
|
if (!raw || !raw.trim()) return null;
|
||||||
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||||
|
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(stage1);
|
||||||
|
const c = _coerce(obj);
|
||||||
|
if (c) return c;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
const balanced = _extractFirstBalancedObject(stage1);
|
||||||
|
if (balanced) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(balanced);
|
||||||
|
const c = _coerce(obj);
|
||||||
|
if (c) return c;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _coerce(obj: unknown): ReturnType<typeof _parseAnalysisJson> {
|
||||||
|
if (!obj || typeof obj !== 'object') return null;
|
||||||
|
const o = obj as Record<string, unknown>;
|
||||||
|
const context = typeof o.context === 'string' ? o.context.trim() : '';
|
||||||
|
const goal = typeof o.goal === 'string' ? o.goal.trim() : '';
|
||||||
|
const format = typeof o.format === 'string' ? o.format.trim() : '';
|
||||||
|
const criteria = Array.isArray(o.criteria)
|
||||||
|
? o.criteria.filter((c): c is string => typeof c === 'string' && c.trim().length > 0)
|
||||||
|
.map((c) => c.trim()).slice(0, 6)
|
||||||
|
: [];
|
||||||
|
const openQuestions = Array.isArray(o.openQuestions)
|
||||||
|
? o.openQuestions.filter((q): q is string => typeof q === 'string' && q.trim().length > 0)
|
||||||
|
.map((q) => q.trim()).slice(0, 4)
|
||||||
|
: [];
|
||||||
|
const conf = typeof o.confidence === 'string' ? o.confidence.trim().toLowerCase() : '';
|
||||||
|
const confidence: 'low' | 'medium' | 'high' =
|
||||||
|
conf === 'high' ? 'high' : conf === 'medium' ? 'medium' : 'low';
|
||||||
|
return { context, goal, criteria, format, openQuestions, confidence };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractFirstBalancedObject(s: string): string | null {
|
||||||
|
const start = s.indexOf('{');
|
||||||
|
if (start === -1) return null;
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
for (let i = start; i < s.length; i++) {
|
||||||
|
const ch = s[i];
|
||||||
|
if (inString) {
|
||||||
|
if (escape) escape = false;
|
||||||
|
else if (ch === '\\') escape = true;
|
||||||
|
else if (ch === '"') inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') { inString = true; continue; }
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
else if (ch === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return s.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end 분석 호출. 절대 throw 하지 않는다 — 호출 실패 / 파싱 실패 시
|
||||||
|
* confidence='low' + 원문만 채워진 contract를 돌려서 호출자가 안전하게
|
||||||
|
* "더 물어봐야 함" 흐름으로 진입할 수 있게 한다. 즉 실패가 *추측 진행*으로
|
||||||
|
* 미끄러지지 않게 한다 — 이 기능의 본질이 추측 방지이므로.
|
||||||
|
*/
|
||||||
|
export async function analyzeIntent(
|
||||||
|
ai: IAIService,
|
||||||
|
input: IntentAnalysisInput,
|
||||||
|
options: { model?: string; timeoutMs?: number } = {},
|
||||||
|
): Promise<IntentAnalysisResult> {
|
||||||
|
const prompt = input.userOriginalPrompt.trim();
|
||||||
|
if (!prompt) {
|
||||||
|
return {
|
||||||
|
contract: _fallbackContract(input.userOriginalPrompt, [
|
||||||
|
'요청 내용이 비어 있습니다. 무엇을 만들고 싶으신가요?',
|
||||||
|
]),
|
||||||
|
raw: '',
|
||||||
|
parsed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let raw = '';
|
||||||
|
try {
|
||||||
|
const result = await ai.chat({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: _buildUserMessage(input),
|
||||||
|
model: options.model,
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
});
|
||||||
|
raw = result.content || '';
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('intentAlignment: analyzer call failed; falling back to low-conf.', {
|
||||||
|
error: e?.message ?? String(e),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
contract: _fallbackContract(input.userOriginalPrompt, [
|
||||||
|
'요청을 더 구체적으로 알려주실 수 있을까요? (분석기 호출 실패)',
|
||||||
|
], input.previousAnswers),
|
||||||
|
raw,
|
||||||
|
parsed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parsed = _parseAnalysisJson(raw);
|
||||||
|
if (!parsed) {
|
||||||
|
logInfo('intentAlignment: parse failed; falling back to low-conf.', {
|
||||||
|
rawHead: raw.slice(0, 100),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
contract: _fallbackContract(input.userOriginalPrompt, [
|
||||||
|
'요청을 더 구체적으로 풀어 설명해 주세요.',
|
||||||
|
], input.previousAnswers),
|
||||||
|
raw,
|
||||||
|
parsed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 이미 사용자가 답한 질문이 새 openQuestions에 다시 끼어 있으면 제거 — 동일
|
||||||
|
// 텍스트 비교는 작은 모델이 약간씩 다르게 바꿔 적어 잡기 어렵지만, 정확한
|
||||||
|
// 중복은 흔하므로 헬퍼로 1차 거름.
|
||||||
|
const askedAlready = new Set((input.previousAnswers ?? []).map((a) => a.q.trim()));
|
||||||
|
const openQuestions = parsed.openQuestions.filter((q) => !askedAlready.has(q.trim()));
|
||||||
|
|
||||||
|
const contract: RequirementContract = {
|
||||||
|
userOriginalPrompt: input.userOriginalPrompt,
|
||||||
|
context: parsed.context,
|
||||||
|
goal: parsed.goal,
|
||||||
|
criteria: parsed.criteria,
|
||||||
|
format: parsed.format,
|
||||||
|
answeredQuestions: input.previousAnswers ? [...input.previousAnswers] : [],
|
||||||
|
openQuestions,
|
||||||
|
// 사용자가 한 라운드 이상 답해줬으면 confidence를 한 단계 끌어올리는
|
||||||
|
// 사후 보정 — 그래야 분석기가 보수적으로 'low'를 고집해도 사용자가
|
||||||
|
// 추가 정보를 줬다는 사실이 반영된다.
|
||||||
|
confidence: _adjustConfidence(parsed.confidence, parsed.openQuestions.length, input.previousAnswers?.length ?? 0),
|
||||||
|
};
|
||||||
|
return { contract, raw, parsed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _adjustConfidence(
|
||||||
|
base: 'low' | 'medium' | 'high',
|
||||||
|
openCount: number,
|
||||||
|
answeredCount: number,
|
||||||
|
): 'low' | 'medium' | 'high' {
|
||||||
|
// 한 라운드 이상 답을 받았는데 분석기가 여전히 low면 medium으로 한 단계만 올림.
|
||||||
|
// 답 한 번에 high로 점프하면 사용자 확인 단계를 너무 빨리 건너뜀.
|
||||||
|
if (answeredCount >= 1 && base === 'low') return 'medium';
|
||||||
|
// openQuestions가 모두 비었으면 medium → high 승격(분석기가 보수적인 경우 보정).
|
||||||
|
if (openCount === 0 && base === 'medium' && answeredCount > 0) return 'high';
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fallbackContract(
|
||||||
|
prompt: string,
|
||||||
|
questions: string[],
|
||||||
|
answered?: Array<{ q: string; a: string }>,
|
||||||
|
): RequirementContract {
|
||||||
|
return {
|
||||||
|
userOriginalPrompt: prompt,
|
||||||
|
context: '',
|
||||||
|
goal: '',
|
||||||
|
criteria: [],
|
||||||
|
format: '',
|
||||||
|
answeredQuestions: answered ? [...answered] : [],
|
||||||
|
openQuestions: questions,
|
||||||
|
confidence: 'low',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract를 LLM 시스템 프롬프트에 끼울 수 있는 마크다운 블록으로 직렬화.
|
||||||
|
* Phase D에서 planner/specialist/reviewer가 모두 이걸 그대로 prepend.
|
||||||
|
* 빈 필드는 "(미)" 로 명시 — 누락이 LLM 시야에서도 *명시적 부재*가 되도록.
|
||||||
|
*/
|
||||||
|
export function formatContractForPrompt(contract: RequirementContract): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('## [REQUIREMENT CONTRACT — 사용자와 사전 합의된 작업 조건]');
|
||||||
|
lines.push(`- **원본 요청**: ${contract.userOriginalPrompt}`);
|
||||||
|
lines.push(`- **맥락 (Context)**: ${contract.context || '(미)'}`);
|
||||||
|
lines.push(`- **목표 (Goal)**: ${contract.goal || '(미)'}`);
|
||||||
|
if (contract.criteria.length > 0) {
|
||||||
|
lines.push('- **판단 기준 (Criteria)**:');
|
||||||
|
for (const c of contract.criteria) lines.push(` - ${c}`);
|
||||||
|
} else {
|
||||||
|
lines.push('- **판단 기준 (Criteria)**: (미)');
|
||||||
|
}
|
||||||
|
lines.push(`- **산출 형식 (Format)**: ${contract.format || '(미)'}`);
|
||||||
|
if (contract.answeredQuestions.length > 0) {
|
||||||
|
lines.push('- **확인된 응답**:');
|
||||||
|
for (const qa of contract.answeredQuestions) {
|
||||||
|
lines.push(` - Q: ${qa.q}`);
|
||||||
|
lines.push(` A: ${qa.a}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contract.openQuestions.length > 0) {
|
||||||
|
lines.push('- **미해결 질문 (사용자가 답 안 받아 보수적으로 처리)**:');
|
||||||
|
for (const q of contract.openQuestions) lines.push(` - ${q}`);
|
||||||
|
}
|
||||||
|
lines.push(`- **신뢰도**: ${contract.confidence}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('위 contract가 모든 판단의 ground truth입니다. 추측이나 contract 외 가정을 추가하지 마세요. 미해결 항목이 작업에 결정적이라면 산출물에 "이 부분은 보수적으로 처리했습니다"라고 명시.');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Intent classifier for 1인 기업 모드 chat input.
|
||||||
|
*
|
||||||
|
* The company mode used to route *every* chat message through the full
|
||||||
|
* dispatcher (CEO planner → specialists → CEO synthesis). That meant
|
||||||
|
* casual messages like "고마워", "방금 그거 다시 보여줘", "이 파일 뭐 하는
|
||||||
|
* 거야?" all kicked off a multi-agent round — wasteful at best, confusing
|
||||||
|
* at worst because the user expects ordinary chat to behave like ordinary
|
||||||
|
* chat regardless of which mode the chip is in.
|
||||||
|
*
|
||||||
|
* This module runs *one* small-model LLM call per message and decides:
|
||||||
|
* - `chat` — greeting / thanks / generic question → answer briefly
|
||||||
|
* - `followup` — refers to the previous round ("그거 다시", "어떻게 됐어?")
|
||||||
|
* - `new_task` — a fresh work request → run the pipeline
|
||||||
|
*
|
||||||
|
* The caller (`chatHandlers`) uses the verdict to route. If the LLM call
|
||||||
|
* fails for any reason we fall back to `new_task` so we never *silently*
|
||||||
|
* eat a real work request — the worst-case is "the classifier misfires
|
||||||
|
* and we run a pipeline we didn't need", same as the old behaviour.
|
||||||
|
*
|
||||||
|
* Returns `intent`, plus a one-line `reason` from the LLM and the raw
|
||||||
|
* response for debug. The reason is shown in the chat label so the user
|
||||||
|
* can tell *why* their message was treated as chat vs. a task.
|
||||||
|
*/
|
||||||
|
import { IAIService } from '../../core/services';
|
||||||
|
import { logError, logInfo } from '../../utils';
|
||||||
|
|
||||||
|
export type ChatIntent = 'chat' | 'followup' | 'new_task';
|
||||||
|
|
||||||
|
export interface IntentResult {
|
||||||
|
intent: ChatIntent;
|
||||||
|
/** One-line Korean explanation from the classifier (or a fallback note). */
|
||||||
|
reason: string;
|
||||||
|
/** Raw LLM body — kept for the debug log. */
|
||||||
|
raw: string;
|
||||||
|
/** True iff the JSON parse succeeded. False means we fell back to default. */
|
||||||
|
parsed: boolean;
|
||||||
|
/**
|
||||||
|
* 분류기가 새 task에 적합하다고 본 파이프라인 id. `new_task` 결과에서만
|
||||||
|
* 의미 있고, classifier가 추천 안 했거나 컨텍스트에 후보가 없으면
|
||||||
|
* undefined. 호출자(chatHandlers)는 사용자 설정
|
||||||
|
* (`companyAutoSelectPipeline`)이 켜져 있을 때만 이 값을 활용해 dispatch
|
||||||
|
* 시점에 활성 파이프라인을 일시 override; 평소엔 사용자가 명시적으로
|
||||||
|
* 활성화해 둔 파이프라인을 그대로 존중.
|
||||||
|
*/
|
||||||
|
suggestedPipelineId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 분류기가 컨텍스트로 받는 파이프라인 후보 한 줄. */
|
||||||
|
export interface PipelineHint {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Short description shown to the classifier so it can pick the right one. */
|
||||||
|
description?: string;
|
||||||
|
/** Number of stages — helps the classifier judge "is this overkill?". */
|
||||||
|
stageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed in from the caller. All fields optional — empty context is
|
||||||
|
* the cold-start case (no prior turn yet).
|
||||||
|
*/
|
||||||
|
export interface IntentContext {
|
||||||
|
/** Brief from the previous turn, if any. */
|
||||||
|
previousBrief?: string;
|
||||||
|
/** Tail of the previous CEO report (truncated by caller). */
|
||||||
|
previousReportTail?: string;
|
||||||
|
/** ISO timestamp of when the previous turn ended, for staleness hints. */
|
||||||
|
previousTurnAt?: number;
|
||||||
|
/** Whether a pipeline is currently configured + active. Tweaks the prompt. */
|
||||||
|
activePipelineName?: string;
|
||||||
|
/**
|
||||||
|
* 분류기가 골라 추천할 후보 파이프라인 리스트. autoSelectPipeline이
|
||||||
|
* 켜져 있을 때만 의미가 있다. 호출자가 비워 보내면 classifier도 추천
|
||||||
|
* 시도조차 안 함.
|
||||||
|
*/
|
||||||
|
availablePipelines?: PipelineHint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_BASE = `당신은 "1인 기업 모드"의 메시지 분류기입니다. 사용자가 방금 보낸 한 줄이 다음 중 무엇인지 한 번에 정확히 판정하세요.
|
||||||
|
|
||||||
|
- "chat" : 인사·감사·잡담·짧은 질문·간단한 정보 요청. 새 프로젝트가 아님.
|
||||||
|
- "followup" : 직전 라운드의 산출물·과정을 가리키는 발화. "그거 다시", "어디까지 했어", "그 결과 보여줘", "방금 그 파일 열어줘" 등.
|
||||||
|
- "new_task" : 새로 시작할 *업무*. 기획·개발·디자인·리서치·QA 등 여러 단계가 필요한 작업 요청. 한 단어라도 "만들어줘"·"기획해줘"·"개발해줘"·"분석해줘" 같이 명확한 새 업무이면 new_task.
|
||||||
|
|
||||||
|
판단 기준:
|
||||||
|
- 직전 라운드 컨텍스트가 있고 사용자 발화가 그것을 가리키면 followup.
|
||||||
|
- 직전 라운드가 없거나(첫 메시지) 직전 내용과 무관하고 새 결과물을 *만들어 달라* 요구이면 new_task.
|
||||||
|
- 위 두 가지가 아니거나 모호하면 chat.
|
||||||
|
- 사용자가 명시적으로 "파이프라인 돌려"·"풀 사이클"·"기획부터" 같은 키워드 쓰면 무조건 new_task.
|
||||||
|
- "고마워"·"잘했어"·"오케이" 같은 짧은 응답은 무조건 chat.`;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_NO_PIPELINE_PICK = `${SYSTEM_PROMPT_BASE}
|
||||||
|
|
||||||
|
⚠️ 반드시 아래 JSON 형식 정확히 한 번. 다른 텍스트(설명, 코드펜스, 머리말) 일체 금지.
|
||||||
|
|
||||||
|
{"intent":"chat"|"followup"|"new_task","reason":"한 줄(20자 이내)"}`;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_WITH_PIPELINE_PICK = `${SYSTEM_PROMPT_BASE}
|
||||||
|
|
||||||
|
new_task인 경우, *사용자 컨텍스트에 제공된 파이프라인 후보 중 가장 적합한 것* 하나도 같이 골라주세요.
|
||||||
|
|
||||||
|
🛑 **사용자 명시 신호 우선 (절대 위반 금지)**:
|
||||||
|
- 사용자 발화에 "기획만"·"기획서까지"·"기획서 작성"·"plan only"·"plan-only"·"기획만 해줘" 등이 있으면 → 후보 중 *기획·plan* 관련 단어가 들어간 가장 짧은 파이프라인을 고르세요. 풀 사이클 절대 금지.
|
||||||
|
- "디자인까지"·"디자인 단계까지" → 디자인 포함, 개발 제외 파이프라인.
|
||||||
|
- "개발까지"·"풀 사이클"·"끝까지"·"배포까지" → 풀 파이프라인.
|
||||||
|
- 명시 신호가 없으면 요청 규모를 보고 짧고 충분한 것 우선. 애매하면 풀 사이클보다 짧은 쪽.
|
||||||
|
- 적당한 후보가 없으면 suggestedPipelineId 필드를 빈 문자열로 두세요.
|
||||||
|
|
||||||
|
⚠️ 사용자가 "기획만 해줘"라고 했는데 개발 stage가 포함된 파이프라인을 골라주면 사용자 의도를 정면으로 무시하는 것입니다. 반드시 짧은 파이프라인 우선.
|
||||||
|
|
||||||
|
⚠️ 반드시 아래 JSON 형식 정확히 한 번. 다른 텍스트(설명, 코드펜스, 머리말) 일체 금지.
|
||||||
|
|
||||||
|
{"intent":"chat"|"followup"|"new_task","reason":"한 줄(20자 이내)","suggestedPipelineId":"<후보 id 또는 빈 문자열>"}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4-stage tolerant JSON parser — same shape as ceoPlanner's. Small models
|
||||||
|
* routinely break the "no extra text" rule with fences / leading prose, so
|
||||||
|
* a strict JSON.parse only catches the well-behaved minority.
|
||||||
|
*/
|
||||||
|
function _parseIntentJson(raw: string): { intent: string; reason: string; suggestedPipelineId?: string } | null {
|
||||||
|
if (!raw || !raw.trim()) return null;
|
||||||
|
|
||||||
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||||
|
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(stage1);
|
||||||
|
const c = _coerce(obj);
|
||||||
|
if (c) return c;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
|
||||||
|
const balanced = _extractFirstBalancedObject(stage1);
|
||||||
|
if (balanced) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(balanced);
|
||||||
|
const c = _coerce(obj);
|
||||||
|
if (c) return c;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: regex pluck — 작은 모델이 JSON 깨뜨려도 핵심 필드만 건짐.
|
||||||
|
const intentMatch = stage1.match(/"intent"\s*:\s*"(chat|followup|new_task)"/i);
|
||||||
|
const reasonMatch = stage1.match(/"reason"\s*:\s*"([^"]*)"/);
|
||||||
|
const pipeMatch = stage1.match(/"suggestedPipelineId"\s*:\s*"([^"]*)"/);
|
||||||
|
if (intentMatch) {
|
||||||
|
return {
|
||||||
|
intent: intentMatch[1],
|
||||||
|
reason: reasonMatch?.[1] ?? '',
|
||||||
|
suggestedPipelineId: pipeMatch?.[1] || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _coerce(obj: unknown): { intent: string; reason: string; suggestedPipelineId?: string } | null {
|
||||||
|
if (!obj || typeof obj !== 'object') return null;
|
||||||
|
const o = obj as Record<string, unknown>;
|
||||||
|
const intent = typeof o.intent === 'string' ? o.intent.trim() : '';
|
||||||
|
const reason = typeof o.reason === 'string' ? o.reason.trim() : '';
|
||||||
|
if (intent !== 'chat' && intent !== 'followup' && intent !== 'new_task') return null;
|
||||||
|
const suggestedPipelineId = typeof o.suggestedPipelineId === 'string' && o.suggestedPipelineId.trim()
|
||||||
|
? o.suggestedPipelineId.trim()
|
||||||
|
: undefined;
|
||||||
|
return { intent, reason, suggestedPipelineId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 발화에서 명시적 범위 신호(예: "기획만", "디자인까지", "풀 사이클")를
|
||||||
|
* 잡아내 일치하는 후보 파이프라인 id로 강제 매핑. LLM 추천이 무시 못 하게
|
||||||
|
* 백엔드 측 안전망. 매칭 못 하면 undefined 반환 → LLM 추천 그대로 사용.
|
||||||
|
*
|
||||||
|
* 매칭 룰:
|
||||||
|
* - "기획만" / "기획서까지" / "plan only" → 이름·설명에 "기획" 또는 "plan"이
|
||||||
|
* 들어가고 개발/배포 단어가 *없는* 파이프라인 중 stageCount 가장 작은 것.
|
||||||
|
* - "디자인까지" / "디자인만" → "design" / "디자인" 단어, 개발 단어 없음.
|
||||||
|
* - "풀 사이클" / "끝까지" / "배포까지" → stageCount 가장 큰 것.
|
||||||
|
*/
|
||||||
|
function _keywordPickPipeline(
|
||||||
|
userPrompt: string,
|
||||||
|
candidates: PipelineHint[],
|
||||||
|
): string | undefined {
|
||||||
|
if (!candidates.length) return undefined;
|
||||||
|
const text = userPrompt.toLowerCase();
|
||||||
|
|
||||||
|
const wantsPlanOnly = /(기획만|기획서까지|기획만\s*해|기획서\s*작성|기획부터\s*기획|plan\s*[-_]?only|plan\s*only)/i.test(text);
|
||||||
|
const wantsDesignStop = /(디자인까지|디자인만|디자인\s*단계까지)/i.test(text);
|
||||||
|
const wantsFull = /(풀\s*사이클|끝까지|배포까지|풀\s*프로덕트|개발까지|production\s*full|full\s*pipeline)/i.test(text);
|
||||||
|
|
||||||
|
const hasDev = (s: string) => /(개발|코드|배포|구현|deploy|develop|dev|implement)/i.test(s);
|
||||||
|
const hasDesign = (s: string) => /(디자인|design|ui)/i.test(s);
|
||||||
|
|
||||||
|
if (wantsPlanOnly) {
|
||||||
|
// 개발/디자인 stage 없는 짧은 기획 파이프라인 우선. 이름/설명 둘 다 본다.
|
||||||
|
const planOnly = candidates
|
||||||
|
.filter((p) => !hasDev(p.name + ' ' + (p.description ?? ''))
|
||||||
|
&& !hasDesign(p.name + ' ' + (p.description ?? '')))
|
||||||
|
.sort((a, b) => a.stageCount - b.stageCount);
|
||||||
|
if (planOnly.length > 0) return planOnly[0].id;
|
||||||
|
// 그것도 없으면 stageCount 가장 작은 후보 (그래도 풀 사이클은 피함).
|
||||||
|
const shortest = [...candidates].sort((a, b) => a.stageCount - b.stageCount)[0];
|
||||||
|
return shortest?.id;
|
||||||
|
}
|
||||||
|
if (wantsDesignStop) {
|
||||||
|
const designStop = candidates
|
||||||
|
.filter((p) => !hasDev(p.name + ' ' + (p.description ?? '')))
|
||||||
|
.sort((a, b) => a.stageCount - b.stageCount);
|
||||||
|
if (designStop.length > 0) return designStop[0].id;
|
||||||
|
}
|
||||||
|
if (wantsFull) {
|
||||||
|
const fullest = [...candidates].sort((a, b) => b.stageCount - a.stageCount)[0];
|
||||||
|
return fullest?.id;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _extractFirstBalancedObject(s: string): string | null {
|
||||||
|
const start = s.indexOf('{');
|
||||||
|
if (start === -1) return null;
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
for (let i = start; i < s.length; i++) {
|
||||||
|
const ch = s[i];
|
||||||
|
if (inString) {
|
||||||
|
if (escape) escape = false;
|
||||||
|
else if (ch === '\\') escape = true;
|
||||||
|
else if (ch === '"') inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') { inString = true; continue; }
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
else if (ch === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return s.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildUserMessage(userPrompt: string, ctx: IntentContext): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (ctx.activePipelineName) {
|
||||||
|
lines.push(`(참고) 현재 활성 파이프라인: "${ctx.activePipelineName}". 사용자가 이 파이프라인을 다시 돌리길 원하면 new_task, 결과 확인이면 followup.`);
|
||||||
|
}
|
||||||
|
if (ctx.previousBrief || ctx.previousReportTail) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[직전 라운드 컨텍스트]');
|
||||||
|
if (ctx.previousTurnAt) {
|
||||||
|
const ageMin = Math.round((Date.now() - ctx.previousTurnAt) / 60000);
|
||||||
|
if (ageMin >= 60) lines.push(`(${Math.round(ageMin / 60)}시간 전 — 오래되었음)`);
|
||||||
|
else lines.push(`(${ageMin}분 전)`);
|
||||||
|
}
|
||||||
|
if (ctx.previousBrief) lines.push(`brief: ${ctx.previousBrief.slice(0, 300)}`);
|
||||||
|
if (ctx.previousReportTail) lines.push(`보고서 끝부분: ${ctx.previousReportTail.slice(0, 300)}`);
|
||||||
|
} else {
|
||||||
|
lines.push('(직전 라운드 컨텍스트 없음 — 첫 메시지이거나 새 세션)');
|
||||||
|
}
|
||||||
|
if (ctx.availablePipelines && ctx.availablePipelines.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[선택 가능한 파이프라인 후보]');
|
||||||
|
for (const p of ctx.availablePipelines) {
|
||||||
|
const desc = p.description ? ` — ${p.description}` : '';
|
||||||
|
lines.push(`- id: ${p.id} | "${p.name}" (${p.stageCount}단계)${desc}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[방금 사용자 메시지]');
|
||||||
|
lines.push(userPrompt);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('판정 JSON만 출력:');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end classification. Never throws — returns a sensible default
|
||||||
|
* (`new_task`) on any failure so the user never silently loses a real
|
||||||
|
* work request.
|
||||||
|
*/
|
||||||
|
export async function classifyChatIntent(
|
||||||
|
ai: IAIService,
|
||||||
|
userPrompt: string,
|
||||||
|
ctx: IntentContext,
|
||||||
|
options: { model?: string; timeoutMs?: number } = {},
|
||||||
|
): Promise<IntentResult> {
|
||||||
|
const trimmed = userPrompt.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { intent: 'chat', reason: '빈 메시지', raw: '', parsed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Heuristic short-circuits ────────────────────────────────────────────
|
||||||
|
// 매우 명확한 chat 신호는 LLM 호출 없이 즉시 결정 — 작은 모델이 흔들리는
|
||||||
|
// 경계를 좁히기 위해 비용 0의 안전망을 둔다. 신호가 약하면 통과시켜
|
||||||
|
// LLM이 판정하게 함.
|
||||||
|
if (trimmed.length <= 8 && /^(고마워|감사|땡큐|ㅇㅇ|ㅇㅋ|네|예|아니|좋아|굿|ok|okay|thanks?|good|great)/i.test(trimmed)) {
|
||||||
|
return { intent: 'chat', reason: '짧은 인사·동의', raw: '', parsed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 후보 파이프라인이 제공됐을 때만 분류기에 "골라 봐" 요청 — 후보 없이 그
|
||||||
|
// 필드를 비워달라고 강제하면 모델이 불필요한 빈값을 채우려다 응답 형식
|
||||||
|
// 깨뜨릴 수 있다.
|
||||||
|
const wantPipelinePick = !!(ctx.availablePipelines && ctx.availablePipelines.length > 0);
|
||||||
|
const system = wantPipelinePick ? SYSTEM_PROMPT_WITH_PIPELINE_PICK : SYSTEM_PROMPT_NO_PIPELINE_PICK;
|
||||||
|
|
||||||
|
let raw = '';
|
||||||
|
try {
|
||||||
|
const result = await ai.chat({
|
||||||
|
system,
|
||||||
|
user: _buildUserMessage(trimmed, ctx),
|
||||||
|
model: options.model,
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
});
|
||||||
|
raw = result.content || '';
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('intentClassifier: AI call failed; defaulting to new_task.', { error: e?.message ?? String(e) });
|
||||||
|
return { intent: 'new_task', reason: '분류 실패 — 안전하게 업무로 처리', raw: '', parsed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = _parseIntentJson(raw);
|
||||||
|
if (!parsed) {
|
||||||
|
logInfo('intentClassifier: parse failed; defaulting to new_task.', { rawHead: raw.slice(0, 100) });
|
||||||
|
return { intent: 'new_task', reason: '판정 형식 불일치 — 업무로 처리', raw, parsed: false };
|
||||||
|
}
|
||||||
|
// suggestedPipelineId는 *제공된 후보 목록 안에 존재할 때만* 신뢰. 분류기가
|
||||||
|
// 환각한 id를 그대로 dispatch에 넘기면 dispatcher에서 silent fallback 발생.
|
||||||
|
let suggestedPipelineId: string | undefined;
|
||||||
|
if (parsed.intent === 'new_task' && parsed.suggestedPipelineId && wantPipelinePick) {
|
||||||
|
const knownIds = new Set((ctx.availablePipelines ?? []).map((p) => p.id));
|
||||||
|
if (knownIds.has(parsed.suggestedPipelineId)) {
|
||||||
|
suggestedPipelineId = parsed.suggestedPipelineId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 백엔드 키워드 fallback — LLM 추천을 *덮어쓴다*. 사용자가 "기획만"·"디자인만"
|
||||||
|
// 같은 명시 신호를 줬는데 LLM이 풀 사이클을 골라버리는 사고를 막기 위함.
|
||||||
|
// 후보 파이프라인 이름/설명에 매칭되는 키워드가 prompt에 있으면 그 쪽을 강제.
|
||||||
|
if (parsed.intent === 'new_task' && wantPipelinePick && ctx.availablePipelines) {
|
||||||
|
const keywordPick = _keywordPickPipeline(trimmed, ctx.availablePipelines);
|
||||||
|
if (keywordPick) suggestedPipelineId = keywordPick;
|
||||||
|
}
|
||||||
|
logInfo('intentClassifier: parsed.', { intent: parsed.intent, reason: parsed.reason, suggestedPipelineId });
|
||||||
|
return {
|
||||||
|
intent: parsed.intent as ChatIntent,
|
||||||
|
reason: parsed.reason || '(이유 없음)',
|
||||||
|
raw,
|
||||||
|
parsed: true,
|
||||||
|
suggestedPipelineId,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -66,10 +66,12 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
suggestedPipelineId: 'product-dev',
|
suggestedPipelineId: 'product-dev',
|
||||||
suggestedPipelineName: '제품 개발 파이프라인',
|
suggestedPipelineName: '제품 개발 파이프라인',
|
||||||
stages: [
|
stages: [
|
||||||
|
// 모든 stage가 *직군*만 지정하고 담당자는 비워둠 (agentId 생략). dispatcher가
|
||||||
|
// stage 진입 시 CEO에게 1회 LLM 콜로 적임자 선택. 활성 후보가 1명뿐이면
|
||||||
|
// 콜 없이 그 사람을 쓴다. 사용자의 의도("CEO가 배분 결정")와 일치.
|
||||||
{
|
{
|
||||||
id: 'plan-discuss',
|
id: 'plan-discuss',
|
||||||
label: '기획 논의',
|
label: '기획 논의',
|
||||||
agentId: 'writer',
|
|
||||||
roleCategory: 'planner',
|
roleCategory: 'planner',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'사용자 요청: {{userPrompt}}\n\n' +
|
'사용자 요청: {{userPrompt}}\n\n' +
|
||||||
@@ -79,7 +81,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'market-research',
|
id: 'market-research',
|
||||||
label: '시장 조사',
|
label: '시장 조사',
|
||||||
agentId: 'researcher',
|
|
||||||
roleCategory: 'researcher',
|
roleCategory: 'researcher',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'기획 논의 정리: {{stage.plan-discuss}}\n\n' +
|
'기획 논의 정리: {{stage.plan-discuss}}\n\n' +
|
||||||
@@ -89,7 +90,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'trend-research',
|
id: 'trend-research',
|
||||||
label: '트렌드 조사',
|
label: '트렌드 조사',
|
||||||
agentId: 'researcher',
|
|
||||||
roleCategory: 'researcher',
|
roleCategory: 'researcher',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'기획 논의: {{stage.plan-discuss}}\n시장 조사 결과: {{stage.market-research}}\n\n' +
|
'기획 논의: {{stage.plan-discuss}}\n시장 조사 결과: {{stage.market-research}}\n\n' +
|
||||||
@@ -99,7 +99,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'direction',
|
id: 'direction',
|
||||||
label: '방향성 정의',
|
label: '방향성 정의',
|
||||||
agentId: 'writer',
|
|
||||||
roleCategory: 'planner',
|
roleCategory: 'planner',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'기획 논의: {{stage.plan-discuss}}\n시장: {{stage.market-research}}\n트렌드: {{stage.trend-research}}\n\n' +
|
'기획 논의: {{stage.plan-discuss}}\n시장: {{stage.market-research}}\n트렌드: {{stage.trend-research}}\n\n' +
|
||||||
@@ -109,7 +108,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'plan-draft',
|
id: 'plan-draft',
|
||||||
label: '기획문서 초안',
|
label: '기획문서 초안',
|
||||||
agentId: 'writer',
|
|
||||||
roleCategory: 'planner',
|
roleCategory: 'planner',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'방향성: {{stage.direction}}\n\n' +
|
'방향성: {{stage.direction}}\n\n' +
|
||||||
@@ -121,7 +119,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'plan-review',
|
id: 'plan-review',
|
||||||
label: '기획문서 검토',
|
label: '기획문서 검토',
|
||||||
agentId: 'inspector',
|
|
||||||
roleCategory: 'inspector',
|
roleCategory: 'inspector',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'검토 대상: {{stage.plan-draft}}\n\n' +
|
'검토 대상: {{stage.plan-draft}}\n\n' +
|
||||||
@@ -136,7 +133,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'plan-final',
|
id: 'plan-final',
|
||||||
label: '기획문서 최종본',
|
label: '기획문서 최종본',
|
||||||
agentId: 'writer',
|
|
||||||
roleCategory: 'planner',
|
roleCategory: 'planner',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'초안: {{stage.plan-draft}}\n검토 피드백: {{stage.plan-review}}\n\n' +
|
'초안: {{stage.plan-draft}}\n검토 피드백: {{stage.plan-review}}\n\n' +
|
||||||
@@ -145,7 +141,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'dev-design',
|
id: 'dev-design',
|
||||||
label: '개발 설계',
|
label: '개발 설계',
|
||||||
agentId: 'developer',
|
|
||||||
roleCategory: 'developer',
|
roleCategory: 'developer',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'최종 기획서: {{stage.plan-final}}\n\n' +
|
'최종 기획서: {{stage.plan-final}}\n\n' +
|
||||||
@@ -155,7 +150,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'design-review',
|
id: 'design-review',
|
||||||
label: '설계 검토',
|
label: '설계 검토',
|
||||||
agentId: 'inspector',
|
|
||||||
roleCategory: 'inspector',
|
roleCategory: 'inspector',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'설계 문서: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' +
|
'설계 문서: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' +
|
||||||
@@ -168,7 +162,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'dev-impl',
|
id: 'dev-impl',
|
||||||
label: '개발 진행',
|
label: '개발 진행',
|
||||||
agentId: 'developer',
|
|
||||||
roleCategory: 'developer',
|
roleCategory: 'developer',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'설계: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' +
|
'설계: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' +
|
||||||
@@ -178,7 +171,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'qa',
|
id: 'qa',
|
||||||
label: 'QA 진행',
|
label: 'QA 진행',
|
||||||
agentId: 'qa',
|
|
||||||
roleCategory: 'qa',
|
roleCategory: 'qa',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'구현 결과: {{stage.dev-impl}}\n기획서: {{stage.plan-final}}\n\n' +
|
'구현 결과: {{stage.dev-impl}}\n기획서: {{stage.plan-final}}\n\n' +
|
||||||
@@ -191,7 +183,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
{
|
{
|
||||||
id: 'deploy',
|
id: 'deploy',
|
||||||
label: '라이브 배포',
|
label: '라이브 배포',
|
||||||
agentId: 'developer',
|
|
||||||
roleCategory: 'developer',
|
roleCategory: 'developer',
|
||||||
instructionTemplate:
|
instructionTemplate:
|
||||||
'QA 통과 결과: {{stage.qa}}\n\n' +
|
'QA 통과 결과: {{stage.qa}}\n\n' +
|
||||||
@@ -201,9 +192,57 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 짧은 "기획만" 워크플로 — 사용자가 기획문서까지만 필요한 경우. 각 산출물
|
||||||
|
* stage에 3-way 검수 사이클을 켜서 셋(작업자 + 감리 + CEO) 합의로 통과
|
||||||
|
* 시키는 패턴을 보여준다. 풀-프로덕트와 달리 별도 review stage를 두지 않고
|
||||||
|
* 사이클로 합쳐서 빠르게 끝낸다.
|
||||||
|
*/
|
||||||
|
const PLAN_ONLY: PipelineTemplate = {
|
||||||
|
templateId: 'plan-only',
|
||||||
|
name: '기획서까지만 (검수 사이클)',
|
||||||
|
description: '시장 조사 → 방향성 → 기획서. 각 산출물 stage에서 검수자 + CEO 합의로 통과시키는 짧은 워크플로.',
|
||||||
|
suggestedPipelineId: 'plan-only',
|
||||||
|
suggestedPipelineName: '기획서 작성',
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: 'market-research',
|
||||||
|
label: '시장 조사',
|
||||||
|
roleCategory: 'researcher',
|
||||||
|
instructionTemplate:
|
||||||
|
'사용자 요청: {{userPrompt}}\n\n' +
|
||||||
|
'이 요청 맥락에서 *시장 측면*을 조사하세요. 추측 금지, 데이터/사례 기반.\n' +
|
||||||
|
'- 비슷한 시도가 이미 있나 (3개 이상)\n- 시장 크기·고객 페르소나\n- 가격대·수익화 패턴\n' +
|
||||||
|
'결과는 "출처(또는 일반론임을 명시)" 표시.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'direction',
|
||||||
|
label: '방향성 정의',
|
||||||
|
roleCategory: 'planner',
|
||||||
|
instructionTemplate:
|
||||||
|
'사용자 요청: {{userPrompt}}\n시장 조사: {{stage.market-research}}\n\n' +
|
||||||
|
'*우리가 갈 방향*을 한 문단으로 결론짓고 측정 가능한 성공 기준을 1~3개 적으세요.',
|
||||||
|
reviewWith: 'inspector',
|
||||||
|
reviewMaxRounds: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plan-doc',
|
||||||
|
label: '기획문서',
|
||||||
|
roleCategory: 'planner',
|
||||||
|
instructionTemplate:
|
||||||
|
'방향성: {{stage.direction}}\n\n' +
|
||||||
|
'아래 섹션 구조로 *기획서*를 마크다운으로 작성하세요. 합의 통과 후엔 사장님께 그대로 전달됩니다.\n\n' +
|
||||||
|
'## 배경\n## 목표\n## 핵심 사용자 시나리오 (3개 이상, 구체적)\n## 주요 기능 목록\n## 비기능 요구사항\n## 측정 지표 (KPI)\n## 미래 확장 / 비-목표',
|
||||||
|
reviewWith: 'inspector',
|
||||||
|
reviewMaxRounds: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
/** Read-only registry of templates the UI surfaces. Add more here later. */
|
/** Read-only registry of templates the UI surfaces. Add more here later. */
|
||||||
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
|
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
|
||||||
FULL_PRODUCT_DEV,
|
FULL_PRODUCT_DEV,
|
||||||
|
PLAN_ONLY,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getPipelineTemplate(id: string): PipelineTemplate | undefined {
|
export function getPipelineTemplate(id: string): PipelineTemplate | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* Pixel Office — Agent Work Pipeline 상태를 시각화하는 *UI Layer 전용* 모듈.
|
||||||
|
*
|
||||||
|
* ─────────────────── 설계 원칙 ───────────────────
|
||||||
|
* 1. **Agent 핵심 판단 로직을 절대 바꾸지 않는다.** Pipeline 진행, contract
|
||||||
|
* 합의, 검수 cycle, 승인 게이트 — 모두 기존 dispatcher / chatHandlers /
|
||||||
|
* SidebarChatProvider 안에서 결정된다. 이 모듈은 그 결정을 *읽고* webview용
|
||||||
|
* 상태 객체로 변환할 뿐이다.
|
||||||
|
* 2. 입력은 기존에 emit되던 이벤트 (CompanyTurnEvent + alignment phase +
|
||||||
|
* intent classifier 결과)뿐. 새로운 인터럽트 포인트를 만들지 않는다.
|
||||||
|
* 3. 출력은 두 종류 — `AgentWorkState` (현재 작업 패널)와 `AgentBubble` 큐
|
||||||
|
* (말풍선 연출). 둘 다 webview가 그대로 받아 그리기만 하면 된다.
|
||||||
|
*
|
||||||
|
* 즉 dispatcher 안의 어떤 한 줄도 "if pixelOffice ..."로 분기하지 않는다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Agent의 현재 단계. 사용자가 명세한 11개 상태값 전부 포함. */
|
||||||
|
export type AgentStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'intake'
|
||||||
|
| 'analyzing'
|
||||||
|
| 'need_clarification'
|
||||||
|
| 'contract_ready'
|
||||||
|
| 'planning'
|
||||||
|
| 'executing'
|
||||||
|
| 'reviewing'
|
||||||
|
| 'waiting_approval'
|
||||||
|
| 'error'
|
||||||
|
| 'done';
|
||||||
|
|
||||||
|
/** 말풍선이 어떤 카테고리에서 나왔는지 — 스타일링 / 우선순위에 활용. */
|
||||||
|
export type BubbleType = 'status' | 'event' | 'warning' | 'error' | 'success';
|
||||||
|
|
||||||
|
/** 말풍선 발생 트리거가 되는 이벤트 — 사용자가 명세한 10개 + 약간 확장. */
|
||||||
|
export type AgentEvent =
|
||||||
|
| 'missing_required_info'
|
||||||
|
| 'clarification_needed'
|
||||||
|
| 'requirement_contract_created'
|
||||||
|
| 'plan_completed'
|
||||||
|
| 'execution_started'
|
||||||
|
| 'review_failed'
|
||||||
|
| 'review_passed'
|
||||||
|
| 'risky_change_detected'
|
||||||
|
| 'approval_required'
|
||||||
|
| 'error_occurred'
|
||||||
|
| 'task_completed'
|
||||||
|
| 'stage_loop_retry';
|
||||||
|
|
||||||
|
export interface AgentWorkState {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
status: AgentStatus;
|
||||||
|
/** 사용자 원본 요청 한 줄 — 패널 상단 "Current Task"에 표시. */
|
||||||
|
currentTask?: string;
|
||||||
|
/** 현재 stage / phase 라벨 — "기획 논의", "QA 진행" 등. */
|
||||||
|
currentStep?: string;
|
||||||
|
/** 다음 stage 라벨 (있으면) — 예측 표시용. */
|
||||||
|
nextStep?: string;
|
||||||
|
/** 짧은 보조 메시지 (예: "라운드 2/3", "검수자: 민지"). */
|
||||||
|
message?: string;
|
||||||
|
/** 진행률 0~1 — 파이프라인 모드일 때 stage index / total로 계산. */
|
||||||
|
progress?: number;
|
||||||
|
/** Requirement Contract 요약 (alignment 완료 후 채워짐). */
|
||||||
|
requirementContract?: {
|
||||||
|
goal?: string;
|
||||||
|
context?: string;
|
||||||
|
criteria?: string[];
|
||||||
|
format?: string;
|
||||||
|
openQuestions?: string[];
|
||||||
|
confidence?: 'low' | 'medium' | 'high';
|
||||||
|
};
|
||||||
|
/** 사용자에게 던지는 미해결 질문 목록 — need_clarification 상태에서 채움. */
|
||||||
|
needUserInput?: string[];
|
||||||
|
/** 승인 대기 중 항목 — waiting_approval 상태에서 채움. */
|
||||||
|
awaitingApproval?: string;
|
||||||
|
/** 짧은 최근 로그 — 사용자가 한눈에 흐름 파악. 최대 6개 ring buffer. */
|
||||||
|
recentLogs?: string[];
|
||||||
|
/** epoch ms — webview의 "n초 전" 표시용. */
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentBubble {
|
||||||
|
id: string;
|
||||||
|
/** 어떤 캐릭터 위에 띄울지 — 단일 캐릭터 모드면 'main' 고정도 가능. */
|
||||||
|
agentId: string;
|
||||||
|
text: string;
|
||||||
|
type: BubbleType;
|
||||||
|
/** 생성 시각 epoch ms. */
|
||||||
|
createdAt: number;
|
||||||
|
/** 자동 사라짐 ms (webview가 사용). 기본값은 webview에서 결정. */
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자가 설정으로 켜고 끌 수 있는 행동 옵션.
|
||||||
|
* webview는 broadcast마다 같이 받아서 즉시 반영.
|
||||||
|
*/
|
||||||
|
export interface PixelOfficeConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
bubblesEnabled: boolean;
|
||||||
|
maxVisibleBubbles: number;
|
||||||
|
bubbleDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────── 상태→말풍선 텍스트 풀 ───────────────────
|
||||||
|
// 사용자가 명세한 톤(가벼운 사무실 코미디) 유지. 무작위 선택을 위해 같은 상태에
|
||||||
|
// 여러 안을 두되 너무 길어지지 않게 4~5개로 제한.
|
||||||
|
|
||||||
|
const STATUS_BUBBLE_POOL: Record<AgentStatus, string[]> = {
|
||||||
|
idle: [
|
||||||
|
'오늘은 무슨 일을 할까?',
|
||||||
|
'주문 대기 중…',
|
||||||
|
'커피 한 잔 더 하고 시작할까.',
|
||||||
|
],
|
||||||
|
intake: [
|
||||||
|
'요청서 들어왔다.',
|
||||||
|
'한번 읽어보자.',
|
||||||
|
'오케이, 뭘 원하시는지 보자.',
|
||||||
|
],
|
||||||
|
analyzing: [
|
||||||
|
'음… 의도가 조금 모호한데?',
|
||||||
|
'맥락부터 정리해보자.',
|
||||||
|
'핵심이 뭐였더라.',
|
||||||
|
'이거 작업 범위가 어디까지지?',
|
||||||
|
],
|
||||||
|
need_clarification: [
|
||||||
|
'이건 사용자 확인이 먼저야.',
|
||||||
|
'질문 하나만 하고 가자.',
|
||||||
|
'추측으로 가면 위험해.',
|
||||||
|
'핵심 정보가 빠졌네.',
|
||||||
|
],
|
||||||
|
contract_ready: [
|
||||||
|
'좋아, 작업 조건 정리 완료.',
|
||||||
|
'이제 방향은 잡혔어.',
|
||||||
|
'계약서 도장 찍었다.',
|
||||||
|
'이제 진짜 시작.',
|
||||||
|
],
|
||||||
|
planning: [
|
||||||
|
'순서부터 잡아보자.',
|
||||||
|
'기존 기능은 건드리지 말자.',
|
||||||
|
'화이트보드 좀 빌릴게.',
|
||||||
|
'단계 나눠서 가자.',
|
||||||
|
],
|
||||||
|
executing: [
|
||||||
|
'코드 들어간다.',
|
||||||
|
'이번엔 단순하게 가자.',
|
||||||
|
'집중 모드 진입.',
|
||||||
|
'키보드 워밍업 완료.',
|
||||||
|
],
|
||||||
|
reviewing: [
|
||||||
|
'잠깐, 이건 다시 보자.',
|
||||||
|
'기존 기능 깨지는지 확인해야 해.',
|
||||||
|
'검수자 시점으로 한 번 더.',
|
||||||
|
'엣지 케이스 빠진 거 없나.',
|
||||||
|
],
|
||||||
|
waiting_approval: [
|
||||||
|
'이건 승인 없이 못 바꿔.',
|
||||||
|
'위험 작업 감지. 확인 필요!',
|
||||||
|
'사장님 결재 부탁드립니다.',
|
||||||
|
'도장 받기 전엔 멈춤.',
|
||||||
|
],
|
||||||
|
error: [
|
||||||
|
'앗, 이건 예상 못 했는데…',
|
||||||
|
'조건 하나 놓쳤네.',
|
||||||
|
'잠깐, 다시 정리.',
|
||||||
|
'엇, 이게 깨졌네.',
|
||||||
|
],
|
||||||
|
done: [
|
||||||
|
'좋아, 끝났다!',
|
||||||
|
'이번 작업 깔끔하게 완료.',
|
||||||
|
'커피 한 잔.',
|
||||||
|
'오늘치 끝!',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_BUBBLE_POOL: Record<AgentEvent, string[]> = {
|
||||||
|
missing_required_info: [
|
||||||
|
'핵심 정보가 빠졌어.',
|
||||||
|
'이거 빠지면 추측해야 해.',
|
||||||
|
],
|
||||||
|
clarification_needed: [
|
||||||
|
'질문 하나만.',
|
||||||
|
'확실하지 않으면 먼저 물어보자.',
|
||||||
|
],
|
||||||
|
requirement_contract_created: [
|
||||||
|
'계약서 도장 찍었다.',
|
||||||
|
'이제 방향은 잡혔어.',
|
||||||
|
],
|
||||||
|
plan_completed: [
|
||||||
|
'계획 정리 끝!',
|
||||||
|
'순서대로 가자.',
|
||||||
|
],
|
||||||
|
execution_started: [
|
||||||
|
'코드 들어간다.',
|
||||||
|
'시작!',
|
||||||
|
],
|
||||||
|
review_failed: [
|
||||||
|
'조건 하나 놓쳤네. 다시 보자.',
|
||||||
|
'너무 복잡하게 가는 거 아냐?',
|
||||||
|
],
|
||||||
|
review_passed: [
|
||||||
|
'검수 통과!',
|
||||||
|
'셋 다 만족이래.',
|
||||||
|
],
|
||||||
|
risky_change_detected: [
|
||||||
|
'잠깐, 이건 승인 필요!',
|
||||||
|
'파일 삭제는 함부로 하면 안 돼.',
|
||||||
|
],
|
||||||
|
approval_required: [
|
||||||
|
'결재 부탁드립니다.',
|
||||||
|
'도장 받고 이어 갈게.',
|
||||||
|
],
|
||||||
|
error_occurred: [
|
||||||
|
'앗, 이건 예상 못 했는데…',
|
||||||
|
'엇, 이게 깨졌네.',
|
||||||
|
],
|
||||||
|
task_completed: [
|
||||||
|
'좋아, 끝났다!',
|
||||||
|
'오늘치 끝.',
|
||||||
|
],
|
||||||
|
stage_loop_retry: [
|
||||||
|
'한 번 더 가자.',
|
||||||
|
'버그 잡고 다시 시도.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 풀에서 하나 뽑기. 같은 결과가 연달아 나오지 않게 lastPicked를
|
||||||
|
* 받아 회피. 풀이 1개뿐이면 어쩔 수 없이 그걸 반환.
|
||||||
|
*/
|
||||||
|
export function pickBubbleText(pool: string[], lastPicked?: string): string {
|
||||||
|
if (!pool || pool.length === 0) return '';
|
||||||
|
if (pool.length === 1) return pool[0];
|
||||||
|
let candidates = pool;
|
||||||
|
if (lastPicked) candidates = pool.filter((s) => s !== lastPicked);
|
||||||
|
if (candidates.length === 0) candidates = pool;
|
||||||
|
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusBubbleText(status: AgentStatus, lastPicked?: string): string {
|
||||||
|
return pickBubbleText(STATUS_BUBBLE_POOL[status] ?? [], lastPicked);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventBubbleText(event: AgentEvent, lastPicked?: string): string {
|
||||||
|
return pickBubbleText(EVENT_BUBBLE_POOL[event] ?? [], lastPicked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이벤트 → 말풍선 type 매핑. 색상/스타일을 webview가 결정할 때 사용. */
|
||||||
|
export function eventBubbleType(event: AgentEvent): BubbleType {
|
||||||
|
switch (event) {
|
||||||
|
case 'error_occurred': return 'error';
|
||||||
|
case 'review_failed':
|
||||||
|
case 'risky_change_detected':
|
||||||
|
case 'missing_required_info':
|
||||||
|
return 'warning';
|
||||||
|
case 'task_completed':
|
||||||
|
case 'review_passed':
|
||||||
|
case 'plan_completed':
|
||||||
|
case 'requirement_contract_created':
|
||||||
|
return 'success';
|
||||||
|
default: return 'event';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AgentBubble factory — id 자동 생성. */
|
||||||
|
export function makeBubble(opts: {
|
||||||
|
agentId: string;
|
||||||
|
text: string;
|
||||||
|
type?: BubbleType;
|
||||||
|
durationMs?: number;
|
||||||
|
}): AgentBubble {
|
||||||
|
return {
|
||||||
|
id: `b-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
agentId: opts.agentId,
|
||||||
|
text: opts.text,
|
||||||
|
type: opts.type ?? 'status',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
durationMs: opts.durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,6 +47,12 @@ export interface SpecialistPromptInputs {
|
|||||||
* Tells the specialist how heavily to rely on the brain context.
|
* Tells the specialist how heavily to rely on the brain context.
|
||||||
*/
|
*/
|
||||||
knowledgeMixPolicy?: string;
|
knowledgeMixPolicy?: string;
|
||||||
|
/**
|
||||||
|
* Intent Alignment 단계에서 도출된 사용자 합의 contract 블록 (이미 마크다운
|
||||||
|
* 으로 직렬화된 상태). 있으면 시스템 프롬프트의 identity 다음·output 규칙
|
||||||
|
* 직전에 prepend 되어 *모든 후속 지시보다 우선하는* ground truth로 동작.
|
||||||
|
*/
|
||||||
|
contractBlock?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +86,14 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
|
|||||||
parts.push(resolved.persona);
|
parts.push(resolved.persona);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Requirement Contract (Intent Alignment) ──
|
||||||
|
// alignment 단계를 거쳤다면 사용자와 합의된 contract가 모든 룰 위에 온다.
|
||||||
|
// 어떤 페르소나·검색 컨텍스트보다도 우선이라는 신호로 출력 규칙 *앞*에 prepend.
|
||||||
|
if (inputs.contractBlock && inputs.contractBlock.trim()) {
|
||||||
|
parts.push('');
|
||||||
|
parts.push(inputs.contractBlock.trim());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Output contract ──
|
// ── Output contract ──
|
||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push('## 출력 규칙');
|
parts.push('## 출력 규칙');
|
||||||
@@ -174,6 +188,21 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
|
|||||||
parts.push(decisions);
|
parts.push(decisions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Self-Reflector Phase A 룰 (가장 마지막에 prepend) ──
|
||||||
|
// 답변 끝에 [Self-Reflector Check] 블록을 자동으로 붙이게 만든다. developer
|
||||||
|
// 직군이면 코드 가드 블록도 함께 — undefined variable / 잘못된 경로 같은
|
||||||
|
// 코드 답변 최빈출 실수에 직격타.
|
||||||
|
try {
|
||||||
|
const { getConfig } = require('../../config') as typeof import('../../config');
|
||||||
|
const { appendSelfReflectorRule } = require('../selfReflector/selfReflectorPrompt') as typeof import('../selfReflector/selfReflectorPrompt');
|
||||||
|
const cfg = getConfig();
|
||||||
|
if (cfg.selfReflectorEnabled) {
|
||||||
|
const base = parts.join('\n');
|
||||||
|
const isCoder = agent.roleCategory === 'developer';
|
||||||
|
return appendSelfReflectorRule(base, { enabled: true, includeCodeGuard: isCoder });
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
|
||||||
return parts.join('\n');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -218,14 +218,19 @@ export interface PipelineStage {
|
|||||||
id: string;
|
id: string;
|
||||||
/** Human label shown in the chat phase header and the editor. */
|
/** Human label shown in the chat phase header and the editor. */
|
||||||
label: string;
|
label: string;
|
||||||
/** Which agent runs this stage. Must resolve via `resolveAgent`. */
|
|
||||||
agentId: string;
|
|
||||||
/**
|
/**
|
||||||
* 직군 hint stored at save time. Lets the editor re-open with the
|
* 명시적으로 지정한 담당 에이전트. 비어 있거나 누락이면 dispatcher가
|
||||||
* correct 직군 dropdown without having to re-derive it from agentId
|
* stage 진입 직전 CEO에게 "이 직군 중 누가 적합?" 한 줄 LLM 콜로
|
||||||
* (handy when the user later changes the agent's 직군 override). The
|
* 결정 — 활성 에이전트가 1명뿐이면 콜 생략하고 그 사람 사용. 매번
|
||||||
* dispatcher itself doesn't read this — it goes straight from agentId
|
* 직군 후보 중에서 CEO가 고르는 게 사용자의 의도(*"CEO가 배분할
|
||||||
* to `resolveAgent`.
|
* 에이전트를 판단"*)와 일치하므로 기본 권장값은 빈 문자열이다.
|
||||||
|
*/
|
||||||
|
agentId?: string;
|
||||||
|
/**
|
||||||
|
* 직군 — *동적 담당자 선택의 핵심 필드*. agentId가 비어 있을 때
|
||||||
|
* dispatcher는 이 직군 안에서만 후보를 추리고 CEO에게 고르게 한다.
|
||||||
|
* agentId가 있어도 UI가 직군 dropdown을 원래 위치로 복원할 수 있게
|
||||||
|
* 같이 저장 — 그 경우 dispatcher는 agentId가 우선이므로 무시.
|
||||||
*/
|
*/
|
||||||
roleCategory?: string;
|
roleCategory?: string;
|
||||||
/**
|
/**
|
||||||
@@ -245,6 +250,25 @@ export interface PipelineStage {
|
|||||||
* to "aborted" cleanly.
|
* to "aborted" cleanly.
|
||||||
*/
|
*/
|
||||||
requiresApproval?: boolean;
|
requiresApproval?: boolean;
|
||||||
|
/**
|
||||||
|
* 3-way 합의 검수 사이클을 켜는 스위치. 값 형식:
|
||||||
|
* - `'inspector'` — `inspector` 직군의 활성 에이전트 자동 선임 (가장 흔한 케이스)
|
||||||
|
* - `'role:<roleCategory>'` — 임의 직군 자동 선임 (예: `'role:qa'`)
|
||||||
|
* - `'agent:<agentId>'` — 특정 에이전트 직접 지정
|
||||||
|
* - 빈값 / 미지정 — 검수 사이클 없음 (legacy 동작)
|
||||||
|
*
|
||||||
|
* 사이클은 매 라운드마다:
|
||||||
|
* 1. 작업자 산출물 (이미 dispatch됨)
|
||||||
|
* 2. 검수자가 "✅ 통과" 또는 "❌ 보완 필요: …" 로 시작하는 코멘트
|
||||||
|
* 3. CEO가 메타-판단 "✅ 통과 / 🔁 보완 / 🛑 중단"
|
||||||
|
* 검수자 ✅ + CEO ✅ → 통과 / 그 외 → 다음 라운드(또는 abort).
|
||||||
|
*/
|
||||||
|
reviewWith?: string;
|
||||||
|
/**
|
||||||
|
* 검수 사이클 최대 라운드 수. 기본 3. 한도 도달하면 강제 통과(경고 표기).
|
||||||
|
* 1 이상 10 이하 — 그 밖의 값은 normalize에서 clamp.
|
||||||
|
*/
|
||||||
|
reviewMaxRounds?: number;
|
||||||
/**
|
/**
|
||||||
* Instruction template. Tokens substituted before dispatch:
|
* Instruction template. Tokens substituted before dispatch:
|
||||||
* - `{{userPrompt}}` — what the user typed
|
* - `{{userPrompt}}` — what the user typed
|
||||||
@@ -271,6 +295,41 @@ export interface PipelineDef {
|
|||||||
stages: PipelineStage[];
|
stages: PipelineStage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intent Alignment — 사용자 자연어 요청을 *실행 가능한 작업 조건*으로 바꾼
|
||||||
|
* 합의문. dispatcher는 turn을 시작하기 전 이 contract를 받고, CEO
|
||||||
|
* planner / specialist prompt / 검수자 prompt 모두에 같은 ground truth로
|
||||||
|
* 주입한다. 결과적으로 "에이전트가 사용자 머릿속을 추측하는" 단계를
|
||||||
|
* 명시적인 데이터로 외부화한 것.
|
||||||
|
*
|
||||||
|
* 필드별 의미 (C-G-C-F-Q):
|
||||||
|
* - context : 현재 상황·프로젝트 맥락. 사용자가 어디서 어떤 상태로
|
||||||
|
* 부터 출발하는지.
|
||||||
|
* - goal : 사용자가 *달성하려는* 결과(behavioural, 1~2 문장).
|
||||||
|
* - criteria : 좋은 결과의 판단 기준. 측정 가능한 형태가 이상적이지만
|
||||||
|
* 정성적 기준도 OK. 빈 배열일 수 있음.
|
||||||
|
* - format : 원하는 산출물의 *형식* (예: "Markdown 기획서",
|
||||||
|
* "Python 단일 스크립트", "JSON 데이터 + 짧은 요약").
|
||||||
|
* - answeredQuestions : alignment 라운드 동안 사용자가 답한 명확화 질문 + 응답
|
||||||
|
* 쌍. 기록용 — pipeline 단계에서도 같이 보여 줘서
|
||||||
|
* "왜 이렇게 잡혔는지" 추적 가능.
|
||||||
|
* - openQuestions : 분석기가 알고 싶었지만 사용자가 답 안 한(또는 안 받기로
|
||||||
|
* 결정한) 질문. 이게 비어 있지 않은 채로 dispatch되면
|
||||||
|
* agent들에게 "이 부분은 모르니 보수적으로" 신호.
|
||||||
|
* - confidence : alignment 단계의 자체 신뢰도. dispatcher가 모드에
|
||||||
|
* 따라 자동 진행 / 사용자 확인 / 추가 질문을 결정.
|
||||||
|
*/
|
||||||
|
export interface RequirementContract {
|
||||||
|
userOriginalPrompt: string;
|
||||||
|
context: string;
|
||||||
|
goal: string;
|
||||||
|
criteria: string[];
|
||||||
|
format: string;
|
||||||
|
answeredQuestions: Array<{ q: string; a: string }>;
|
||||||
|
openQuestions: string[];
|
||||||
|
confidence: 'low' | 'medium' | 'high';
|
||||||
|
}
|
||||||
|
|
||||||
/** Output of the CEO planner LLM call after JSON parsing. */
|
/** Output of the CEO planner LLM call after JSON parsing. */
|
||||||
export interface CompanyTaskPlan {
|
export interface CompanyTaskPlan {
|
||||||
/** 2-3 sentence Korean summary of what the company is going to do. */
|
/** 2-3 sentence Korean summary of what the company is going to do. */
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Self-Reflector Phase C — *실행 기반* 검증.
|
||||||
|
*
|
||||||
|
* Phase A/B는 LLM 텍스트 분석에 의존하므로 "코드가 실제로 컴파일되는가?"
|
||||||
|
* 같은 질문엔 한계가 있다. Phase C는 정답: 그냥 *실행해 본다*.
|
||||||
|
*
|
||||||
|
* 동작:
|
||||||
|
* 1. action-tag executor가 반환한 report를 받아 `✅ Created: <path>` /
|
||||||
|
* `✅ Edited: <path>` 항목에서 경로를 추출
|
||||||
|
* 2. 파일 확장자별 toolchain 선택:
|
||||||
|
* .py → `python -m py_compile <path>`
|
||||||
|
* .js / .mjs / .cjs → `node --check <path>`
|
||||||
|
* .ts / .tsx → 프로젝트 단위 `tsc --noEmit` (단일 파일 체크는 의존성 때문에 실패율 높음)
|
||||||
|
* .json → `JSON.parse` (node)
|
||||||
|
* 3. exitCode 0이면 ✅, 아니면 ❌ + 첫 줄 에러 메시지 캡쳐
|
||||||
|
* 4. 추가 report 항목으로 결과 반환
|
||||||
|
*
|
||||||
|
* 안전장치:
|
||||||
|
* - timeout 10초 (절대 멈춰선 안 됨)
|
||||||
|
* - 도구 미설치 / spawn 실패 → 경고 한 줄로 마무리 (블로킹 X)
|
||||||
|
* - 워크스페이스 외부 경로 무시
|
||||||
|
* - 사용자가 `executionVerification=false`면 통째로 skip — 호출자가 가드
|
||||||
|
*/
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { logError, logInfo } from '../../utils';
|
||||||
|
|
||||||
|
export type ExecCheckResult = {
|
||||||
|
relPath: string;
|
||||||
|
ok: boolean;
|
||||||
|
/** 도구 없음 / 환경 문제 등 *체크 자체*가 실패 — ok=false로 분류하되 errorLine은 도구 부재 메시지. */
|
||||||
|
toolMissing?: boolean;
|
||||||
|
/** 실패 시 첫 줄 에러 메시지. ok=true면 비어 있음. */
|
||||||
|
errorLine?: string;
|
||||||
|
/** 사용한 도구 명령(디버그용). */
|
||||||
|
tool: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 한 명령을 spawn, 표준 출력+에러 캡쳐, timeout 후 강제 종료. */
|
||||||
|
function _runCheck(cmd: string, args: string[], cwd: string, timeoutMs = 10000): Promise<{ code: number; out: string; err: string; spawnFailed?: boolean }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let out = '', err = '';
|
||||||
|
let settled = false;
|
||||||
|
let proc: ReturnType<typeof spawn> | undefined;
|
||||||
|
try {
|
||||||
|
proc = spawn(cmd, args, { cwd, shell: false, windowsHide: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
resolve({ code: -1, out: '', err: e?.message ?? String(e), spawnFailed: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
try { proc?.kill('SIGKILL'); } catch { /* noop */ }
|
||||||
|
settled = true;
|
||||||
|
resolve({ code: -2, out, err: err + '\n[timeout after ' + timeoutMs + 'ms]' });
|
||||||
|
}, timeoutMs);
|
||||||
|
proc.stdout?.on('data', (b) => { out += b.toString(); });
|
||||||
|
proc.stderr?.on('data', (b) => { err += b.toString(); });
|
||||||
|
proc.on('error', (e: any) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code: -1, out, err: e?.message ?? String(e), spawnFailed: true });
|
||||||
|
});
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code: code ?? 0, out, err });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _firstNonEmptyLine(s: string): string {
|
||||||
|
return (s || '').split(/\r?\n/).map((x) => x.trim()).find((x) => x.length > 0) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 확장자별 검사 명령 결정. 지원 안 하는 확장자면 null 반환 (skip). */
|
||||||
|
function _pickTool(absPath: string, projectRoot: string): { cmd: string; args: string[]; cwd: string; label: string } | null {
|
||||||
|
const ext = path.extname(absPath).toLowerCase();
|
||||||
|
if (ext === '.py') {
|
||||||
|
return { cmd: 'python', args: ['-m', 'py_compile', absPath], cwd: projectRoot, label: 'py_compile' };
|
||||||
|
}
|
||||||
|
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
||||||
|
return { cmd: 'node', args: ['--check', absPath], cwd: projectRoot, label: 'node --check' };
|
||||||
|
}
|
||||||
|
if (ext === '.json') {
|
||||||
|
// node -e "JSON.parse(fs.readFileSync(...))"
|
||||||
|
return {
|
||||||
|
cmd: 'node',
|
||||||
|
args: ['-e', `JSON.parse(require('fs').readFileSync(${JSON.stringify(absPath)},'utf8'))`],
|
||||||
|
cwd: projectRoot,
|
||||||
|
label: 'node JSON.parse',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ext === '.ts' || ext === '.tsx') {
|
||||||
|
// 단일 파일 tsc는 의존성 때문에 false-positive가 많아 *프로젝트 단위* noEmit으로 돌린다.
|
||||||
|
// 비용은 더 크지만 실제 사용자 환경에서 의미 있는 결과를 낸다.
|
||||||
|
const tsconfig = path.join(projectRoot, 'tsconfig.json');
|
||||||
|
if (!fs.existsSync(tsconfig)) return null;
|
||||||
|
return {
|
||||||
|
cmd: 'npx', args: ['--no-install', 'tsc', '--noEmit', '-p', tsconfig],
|
||||||
|
cwd: projectRoot, label: 'tsc --noEmit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* report 한 줄에서 `✅ Created: foo.py` / `✅ Edited: foo.py` 형태의 경로 추출.
|
||||||
|
* 이 두 케이스만 의미 있음 (Listed/Reveal 등은 syntax 체크 대상 아님).
|
||||||
|
*/
|
||||||
|
function _extractPathFromReportLine(line: string): string | null {
|
||||||
|
const m = line.match(/^\s*[✅⚠️]\s*(?:Created|Edited|Updated)\s*:\s*(.+)$/);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* report 내 모든 file action에 대해 syntax 체크 실행. 추가 report 라인들을 반환.
|
||||||
|
* 호출자가 기존 actionReport에 concat 해서 사용자에게 보여주기만 하면 됨.
|
||||||
|
*
|
||||||
|
* @param report executeActionTags가 반환한 원본 report
|
||||||
|
* @param projectRoot 현재 워크스페이스 루트 (cwd로 사용)
|
||||||
|
* @returns 추가 report 라인 (없으면 빈 배열 — 검증 대상 파일 없음)
|
||||||
|
*/
|
||||||
|
export async function verifyCreatedFiles(report: string[], projectRoot: string): Promise<string[]> {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
for (const line of report) {
|
||||||
|
const rel = _extractPathFromReportLine(line);
|
||||||
|
if (!rel) continue;
|
||||||
|
const abs = path.isAbsolute(rel) ? rel : path.join(projectRoot, rel);
|
||||||
|
// 워크스페이스 외부 / 존재하지 않는 파일 skip.
|
||||||
|
if (!abs.startsWith(projectRoot)) continue;
|
||||||
|
if (!fs.existsSync(abs)) continue;
|
||||||
|
candidates.push(abs);
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return [];
|
||||||
|
|
||||||
|
// TypeScript 프로젝트 단위 체크는 *한 번만* 돌리면 됨 (모든 .ts 파일 커버).
|
||||||
|
// 그래서 ts 파일이 여럿이어도 tsc는 한 번만 호출.
|
||||||
|
const ranTsForProject = new Set<string>(); // projectRoot 단위로
|
||||||
|
const out: string[] = [];
|
||||||
|
|
||||||
|
for (const abs of candidates) {
|
||||||
|
const tool = _pickTool(abs, projectRoot);
|
||||||
|
const rel = path.relative(projectRoot, abs);
|
||||||
|
if (!tool) continue;
|
||||||
|
// ts 프로젝트 체크 중복 회피.
|
||||||
|
if (tool.label === 'tsc --noEmit') {
|
||||||
|
if (ranTsForProject.has(tool.cwd)) continue;
|
||||||
|
ranTsForProject.add(tool.cwd);
|
||||||
|
}
|
||||||
|
const t0 = Date.now();
|
||||||
|
const res = await _runCheck(tool.cmd, tool.args, tool.cwd);
|
||||||
|
const dur = ((Date.now() - t0) / 1000).toFixed(1);
|
||||||
|
if (res.spawnFailed) {
|
||||||
|
// 도구 미설치 — warning 한 줄로 마무리, 차단하지 않음.
|
||||||
|
out.push(`⚠️ ${tool.label} 미설치 — ${rel} 검증 skip`);
|
||||||
|
logInfo('selfReflector.C: tool missing.', { tool: tool.label, path: rel });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (res.code === 0) {
|
||||||
|
out.push(`🔬 ${tool.label} OK: ${rel} (${dur}s)`);
|
||||||
|
} else {
|
||||||
|
const errLine = _firstNonEmptyLine(res.err || res.out) || `exit ${res.code}`;
|
||||||
|
out.push(`❌ ${tool.label} FAIL: ${rel} — ${errLine}`);
|
||||||
|
logError('selfReflector.C: syntax check failed.', { path: rel, tool: tool.label, code: res.code, err: errLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* Self-Reflector — *빈 깡통(Hollow Code)* 검출 휴리스틱.
|
||||||
|
*
|
||||||
|
* Phase C(syntax/lint)는 문법 오류만 잡는다. 작은 LLM이 가장 자주 만드는
|
||||||
|
* 실패 패턴은 *문법은 맞지만 본문이 비어 있는* 코드 — `def foo(): pass`,
|
||||||
|
* `# TODO: implement`, import만 있고 로직 0줄인 모듈 등. 사용자가 "완료
|
||||||
|
* 됐다"라는 응답을 받고 파일을 열면 빈 깡통만 들어 있는 사고가 여기서
|
||||||
|
* 나온다.
|
||||||
|
*
|
||||||
|
* 이 모듈은 *정규식 + 라인 카운팅* 만으로 빈 깡통 패턴을 잡는다. LLM 콜
|
||||||
|
* 0회, 추가 비용 0. 한계는 있지만(일부 위양성/위음성) 작은 모델 실패의
|
||||||
|
* 80% 이상은 이 단순 휴리스틱으로 잡힌다.
|
||||||
|
*
|
||||||
|
* 호출자(dispatcher / agent.ts)가 syntax 체크 직후에 같이 부르면:
|
||||||
|
* ✅ 검증 통과 → 그대로 응답
|
||||||
|
* ❌ 빈 깡통 → action-report에 한 줄 경고 추가 + (회사 모드면 verifier
|
||||||
|
* retry 트리거 조건에 합류)
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/** 한 파일의 검사 결과. ok=false면 reasons에 잡힌 패턴들. */
|
||||||
|
export interface HollowCheckResult {
|
||||||
|
relPath: string;
|
||||||
|
ok: boolean;
|
||||||
|
/** ok=false일 때 잡힌 사유 1~3줄. */
|
||||||
|
reasons: string[];
|
||||||
|
/** 진단 메타 — 디버그/로깅용. */
|
||||||
|
meta?: {
|
||||||
|
totalLines: number;
|
||||||
|
codeLines: number;
|
||||||
|
stubFnRatio: number;
|
||||||
|
todoRatio: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 어떤 파일 확장자를 hollow 검사 대상으로 삼을지. */
|
||||||
|
function _isSupportedExt(ext: string): boolean {
|
||||||
|
return ['.py', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라인이 *의미 있는 코드*인지 판정. 주석/빈줄/혼자 떠 있는 닫는 괄호 등은
|
||||||
|
* 의미 라인이 아니다. 작은 파일도 너무 가혹하게 평가하지 않도록 import는
|
||||||
|
* 의미 라인으로 인정 (재export 모듈 등 유효한 패턴 보호).
|
||||||
|
*/
|
||||||
|
function _isMeaningfulCodeLine(line: string, ext: string): boolean {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) return false;
|
||||||
|
// 한줄/블록 주석 단독 라인
|
||||||
|
if (ext === '.py') {
|
||||||
|
if (t.startsWith('#')) return false;
|
||||||
|
if (t.startsWith('"""') || t.startsWith("'''")) return false;
|
||||||
|
} else {
|
||||||
|
if (t.startsWith('//')) return false;
|
||||||
|
if (t.startsWith('/*') || t.startsWith('*') || t === '*/') return false;
|
||||||
|
}
|
||||||
|
// 혼자 떠 있는 brace / 괄호
|
||||||
|
if (/^[\}\)\]\s]+;?$/.test(t)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 라인이 stub 의심 표현인지. */
|
||||||
|
function _isStubLine(line: string): boolean {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) return false;
|
||||||
|
if (t === 'pass') return true;
|
||||||
|
if (t === '...') return true;
|
||||||
|
if (/^return\s*(None|null|undefined)?\s*;?$/.test(t)) return true;
|
||||||
|
if (/^(?:#|\/\/)\s*(TODO|FIXME|XXX|HACK|implement|구현|placeholder|여기에)/i.test(t)) return true;
|
||||||
|
if (/^["']?(TODO|FIXME|TBD|placeholder|구현 필요|여기에 구현)["']?\s*$/i.test(t)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Python 함수/메서드의 본문이 stub뿐인지 판정.
|
||||||
|
* 본문은 def 시그니처 다음 들여쓰기 된 라인들. 닫히는 시점은 들여쓰기 감소.
|
||||||
|
*/
|
||||||
|
function _countPyHollowFunctions(src: string): { total: number; hollow: number } {
|
||||||
|
const lines = src.split(/\r?\n/);
|
||||||
|
let total = 0, hollow = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const m = lines[i].match(/^(\s*)def\s+\w+\s*\(/);
|
||||||
|
if (!m) continue;
|
||||||
|
total++;
|
||||||
|
const baseIndent = m[1].length;
|
||||||
|
const bodyLines: string[] = [];
|
||||||
|
for (let j = i + 1; j < lines.length; j++) {
|
||||||
|
const ln = lines[j];
|
||||||
|
if (!ln.trim()) continue;
|
||||||
|
const indent = (ln.match(/^\s*/)?.[0].length) ?? 0;
|
||||||
|
if (indent <= baseIndent) break;
|
||||||
|
bodyLines.push(ln);
|
||||||
|
}
|
||||||
|
// docstring 한 줄 무시.
|
||||||
|
const cleaned = bodyLines.filter((l) => {
|
||||||
|
const t = l.trim();
|
||||||
|
return !!t && !t.startsWith('"""') && !t.startsWith("'''") && !t.startsWith('#');
|
||||||
|
});
|
||||||
|
if (cleaned.length === 0 || cleaned.every((l) => _isStubLine(l))) {
|
||||||
|
hollow++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { total, hollow };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS/TS의 *간단한* hollow 함수 카운트. 정확한 AST 파싱은 비용 큼 →
|
||||||
|
* 정규식만으로 충분히 잡히는 패턴 위주.
|
||||||
|
* 패턴: `function X(...) { ...body... }` / `X(...) { ...body... }` (메서드)
|
||||||
|
* / `() => { ... }` 화살표
|
||||||
|
* body 분석은 첫 \`{\` ~ 매칭되는 \`}\` 까지 brace 카운팅.
|
||||||
|
*/
|
||||||
|
function _countJsHollowFunctions(src: string): { total: number; hollow: number } {
|
||||||
|
let total = 0, hollow = 0;
|
||||||
|
// 시그니처 시작점 후보들.
|
||||||
|
const sigRe = /(?:function\s*\w*\s*\([^)]*\)\s*\{|=>\s*\{|\b\w+\s*\([^)]*\)\s*\{)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = sigRe.exec(src)) !== null) {
|
||||||
|
const openIdx = src.indexOf('{', m.index);
|
||||||
|
if (openIdx === -1) continue;
|
||||||
|
total++;
|
||||||
|
// matching close
|
||||||
|
let depth = 1, j = openIdx + 1;
|
||||||
|
let inStr: string | null = null;
|
||||||
|
for (; j < src.length; j++) {
|
||||||
|
const ch = src[j];
|
||||||
|
if (inStr) {
|
||||||
|
if (ch === '\\') { j++; continue; }
|
||||||
|
if (ch === inStr) inStr = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
else if (ch === '}') { depth--; if (depth === 0) break; }
|
||||||
|
}
|
||||||
|
if (depth !== 0) continue; // 짝 안 맞으면 skip
|
||||||
|
const body = src.slice(openIdx + 1, j);
|
||||||
|
// body 내용 의미 라인만.
|
||||||
|
const meaningful = body.split(/\r?\n/).filter((l) => {
|
||||||
|
const t = l.trim();
|
||||||
|
if (!t) return false;
|
||||||
|
if (t.startsWith('//') || t.startsWith('/*') || t.startsWith('*')) return false;
|
||||||
|
if (_isStubLine(t)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (meaningful.length === 0) hollow++;
|
||||||
|
}
|
||||||
|
return { total, hollow };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단일 파일 검사. 지원 안 하는 확장자면 ok 반환 (skip). */
|
||||||
|
export function checkHollow(absPath: string, projectRoot: string): HollowCheckResult {
|
||||||
|
const relPath = path.relative(projectRoot, absPath);
|
||||||
|
const ext = path.extname(absPath).toLowerCase();
|
||||||
|
const result: HollowCheckResult = { relPath, ok: true, reasons: [] };
|
||||||
|
if (!_isSupportedExt(ext)) return result;
|
||||||
|
|
||||||
|
let src = '';
|
||||||
|
try {
|
||||||
|
src = fs.readFileSync(absPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return result; // 못 읽으면 검사 skip (다른 검증 layer가 잡음)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = src.split(/\r?\n/);
|
||||||
|
const totalLines = lines.length;
|
||||||
|
const codeLines = lines.filter((l) => _isMeaningfulCodeLine(l, ext)).length;
|
||||||
|
const stubLines = lines.filter(_isStubLine).length;
|
||||||
|
const todoMatches = (src.match(/\b(?:TODO|FIXME|XXX|HACK)\b/gi) ?? []).length;
|
||||||
|
|
||||||
|
const reasons: string[] = [];
|
||||||
|
|
||||||
|
// 패턴 1 — 코드 라인이 너무 적음 (단순 모듈 보호: 6줄 미만은 의심).
|
||||||
|
if (codeLines < 4) {
|
||||||
|
reasons.push(`의미 있는 코드가 너무 적음 (${codeLines}줄)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패턴 2 — stub 비율이 높음.
|
||||||
|
const stubFnRes = (ext === '.py')
|
||||||
|
? _countPyHollowFunctions(src)
|
||||||
|
: _countJsHollowFunctions(src);
|
||||||
|
const stubFnRatio = stubFnRes.total > 0 ? stubFnRes.hollow / stubFnRes.total : 0;
|
||||||
|
if (stubFnRes.total > 0 && stubFnRes.hollow === stubFnRes.total) {
|
||||||
|
reasons.push(`모든 함수(${stubFnRes.total}개)가 stub만 있음`);
|
||||||
|
} else if (stubFnRes.total >= 2 && stubFnRatio >= 0.5) {
|
||||||
|
reasons.push(`함수 ${stubFnRes.hollow}/${stubFnRes.total}개가 stub`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패턴 3 — TODO/FIXME 텍스트 라벨이 코드 라인 수를 압도.
|
||||||
|
const todoRatio = codeLines > 0 ? todoMatches / codeLines : 0;
|
||||||
|
if (todoMatches >= 2 && todoRatio >= 0.5) {
|
||||||
|
reasons.push(`TODO/FIXME가 너무 많음 (${todoMatches}개)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패턴 4 — 파일 전체가 import만.
|
||||||
|
const allImports = lines.every((l) => {
|
||||||
|
const t = l.trim();
|
||||||
|
if (!t) return true;
|
||||||
|
if (t.startsWith('#') || t.startsWith('//')) return true;
|
||||||
|
if (ext === '.py') {
|
||||||
|
return /^(?:from\s+\S+\s+import\s|import\s)/.test(t);
|
||||||
|
}
|
||||||
|
return /^(?:import\s|export\s+\{|export\s+\*|export\s+default\s+from\s|const\s+\w+\s*=\s*require\()/.test(t);
|
||||||
|
});
|
||||||
|
if (allImports && codeLines > 0) {
|
||||||
|
reasons.push('파일에 import 외 실제 로직이 없음');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasons.length > 0) {
|
||||||
|
result.ok = false;
|
||||||
|
result.reasons = reasons;
|
||||||
|
}
|
||||||
|
result.meta = { totalLines, codeLines, stubFnRatio, todoRatio };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* report 한 줄에서 `✅ Created: foo.py` / `✅ Edited: foo.py` 형태 경로 추출.
|
||||||
|
* Phase C와 동일 로직.
|
||||||
|
*/
|
||||||
|
function _extractPathFromReportLine(line: string): string | null {
|
||||||
|
const m = line.match(/^\s*[✅⚠️]\s*(?:Created|Edited|Updated)\s*:\s*(.+)$/);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* report 내 모든 생성/편집 파일에 대해 hollow 검사 실행. 추가 report 라인을
|
||||||
|
* 반환 — 호출자가 actionReport에 그대로 append.
|
||||||
|
*
|
||||||
|
* @returns 빈 깡통 경고 라인들 + ok 통과 라인. 검사 대상 없으면 빈 배열.
|
||||||
|
*/
|
||||||
|
export function verifyHollow(report: string[], projectRoot: string): {
|
||||||
|
extraLines: string[];
|
||||||
|
hasHollow: boolean;
|
||||||
|
hollowReasons: string[];
|
||||||
|
} {
|
||||||
|
const extraLines: string[] = [];
|
||||||
|
const hollowReasons: string[] = [];
|
||||||
|
for (const line of report) {
|
||||||
|
const rel = _extractPathFromReportLine(line);
|
||||||
|
if (!rel) continue;
|
||||||
|
const abs = path.isAbsolute(rel) ? rel : path.join(projectRoot, rel);
|
||||||
|
if (!abs.startsWith(projectRoot)) continue;
|
||||||
|
if (!fs.existsSync(abs)) continue;
|
||||||
|
const check = checkHollow(abs, projectRoot);
|
||||||
|
const ext = path.extname(abs).toLowerCase();
|
||||||
|
if (!_isSupportedExt(ext)) continue;
|
||||||
|
if (check.ok) {
|
||||||
|
// 통과는 표시 안 함 (노이즈) — 실패만 보고.
|
||||||
|
} else {
|
||||||
|
const reasonStr = check.reasons.join(' / ');
|
||||||
|
extraLines.push(`❌ Hollow code: ${check.relPath} — ${reasonStr}`);
|
||||||
|
for (const r of check.reasons) hollowReasons.push(`${check.relPath}: ${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { extraLines, hasHollow: extraLines.length > 0, hollowReasons };
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Self-Reflector — 답변 산출물의 *자기 검증* 레이어.
|
||||||
|
*
|
||||||
|
* Memory(기억) 단계는 이미 충분히 강하지만 Verification(검증) 단계는 사용자
|
||||||
|
* 피드백에 의존적이다. 이 모듈은 그 격차를 메꾸는 3단 구조:
|
||||||
|
*
|
||||||
|
* Phase A (이 파일) ─ 시스템 프롬프트에 self-check 체크리스트 룰을 박아
|
||||||
|
* LLM이 *응답 마지막에* [Self-Reflector Check] 섹션을 자동으로 붙이게
|
||||||
|
* 한다. 추가 LLM 콜 비용 0, constraint-driven generation 효과로 응답
|
||||||
|
* 품질 자체가 올라간다.
|
||||||
|
*
|
||||||
|
* Phase B (selfReflectorVerifier.ts) ─ 응답 직후 *분리된 콘텍스트*에서
|
||||||
|
* LLM 한 번 더 호출해 외부 시각으로 검증. 같은 모델·같은 콘텍스트의
|
||||||
|
* 한계를 보완. 회사 모드 specialist 응답에만 옵션으로 적용.
|
||||||
|
*
|
||||||
|
* Phase C (selfReflectorExecution.ts) ─ 코드 답변에 한해 syntax/lint를
|
||||||
|
* 실제로 돌려 *실행 기반* 검증. 텍스트 검증으로는 잡지 못하는
|
||||||
|
* undefined variable, 잘못된 import 등을 잡아낸다.
|
||||||
|
*
|
||||||
|
* 세 phase 모두 사용자가 설정 토글로 끌 수 있어야 한다. 본질적으로 *추측을
|
||||||
|
* 자기 검증으로 누르는* 안전망이라 끌 이유는 별로 없지만, 비용이나 latency가
|
||||||
|
* 부담스러운 경우(특히 Phase B/C)는 OFF 권장.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 LLM 응답 끝에 자동으로 붙는 self-check 룰 블록. 시스템 프롬프트의
|
||||||
|
* 가장 끝에 prepend 되어 다른 어떤 룰보다도 *마지막에* 적용된다.
|
||||||
|
*
|
||||||
|
* 작성 원칙:
|
||||||
|
* - 항목은 4개 이내 — 너무 많으면 LLM이 무성의하게 채움
|
||||||
|
* - 코드 답변에는 추가 항목 (References / Paths) — 결과가 디스크에 떨어지는
|
||||||
|
* 케이스의 가장 흔한 실수 두 가지를 직접 잡는다
|
||||||
|
* - "Yes/Checked/N-A" 같은 일관된 마커를 강제해서 사용자가 시선 흐름으로
|
||||||
|
* 스캔 가능하게
|
||||||
|
* - 자기 비판이 아니라 *사실 확인*. 본문에서 이미 했더라도 한 번 더 명시.
|
||||||
|
*/
|
||||||
|
export const SELF_REFLECTOR_RULE_BLOCK = `
|
||||||
|
## [Self-Reflector Check — 매 답변 끝에 *반드시* 추가]
|
||||||
|
|
||||||
|
답변 본문이 끝난 직후, 빈 줄 한 줄을 두고 아래 형식의 self-check 블록을 *마지막 출력*으로 붙이세요. 본문이 한 줄짜리 잡담(인사·동의)인 경우는 생략 가능하지만, 코드·구조 설명·결정 사항이 포함된 답변은 *예외 없이* 붙입니다.
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
[Self-Reflector Check]
|
||||||
|
- Consistency: <Yes / 해당 없음 / 충돌 항목 1줄>
|
||||||
|
- Completeness: <Yes / 누락 항목 1줄>
|
||||||
|
- Accuracy: <Checked / 의심 항목 1줄>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
답변에 *코드 / 파일 경로 / 명령*이 포함됐다면 아래 두 줄을 추가:
|
||||||
|
\`\`\`
|
||||||
|
- References: <Verified / 정의되지 않은 변수·함수 의심 1줄>
|
||||||
|
- Paths: <Verified / 의심 경로 1줄>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- 항목 값은 짧게(한 줄). 본문 반복 금지.
|
||||||
|
- "Checked"는 *방금 검토했다*는 뜻이지 "완벽하다"는 뜻이 아닙니다. 의심이 있으면 그 의심을 적으세요.
|
||||||
|
- 자기 평가를 부드럽게 포장하지 마세요. 누락이 있으면 누락이라고 적습니다.
|
||||||
|
- 이 블록은 사용자를 위한 *투명성 장치*입니다 — 사용자가 답변 신뢰도를 빠르게 가늠할 수 있어야 합니다.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 코드 답변에 한해 추가하는 강한 가드. promptBuilder가 stage의 직군이
|
||||||
|
* developer면 specialist prompt에 추가로 prepend 한다.
|
||||||
|
*/
|
||||||
|
export const SELF_REFLECTOR_CODE_GUARD = `
|
||||||
|
## [Code Self-Verification — 코드 작성 시 추가 검증]
|
||||||
|
|
||||||
|
코드 / 파일을 작성하기 *전에* 다음을 머릿속에서 한 번 더 점검:
|
||||||
|
1. 참조하는 변수·함수·import가 *실제로 존재*하거나 *이 응답 안에서 정의*되는가
|
||||||
|
2. 파일 경로가 워크스페이스 안인가 (절대 경로는 워크스페이스 루트 하위여야 함)
|
||||||
|
3. 기존 파일을 수정하는 경우 \`<read_file>\` 으로 먼저 *현재 내용을 확인*한 뒤 \`<edit_file>\`로 부분 변경
|
||||||
|
4. 새 파일이 의존하는 패키지가 프로젝트에 이미 있는지 (없으면 답변 본문에 "추가 설치 필요" 명시)
|
||||||
|
|
||||||
|
🛑 **빈 깡통 코드 절대 금지 (이 룰을 어기면 즉시 재작업)**
|
||||||
|
다음 패턴 중 하나라도 만든 파일에 있으면 *완성된 코드가 아닙니다*:
|
||||||
|
- 본문이 \`pass\` 한 줄뿐인 함수
|
||||||
|
- 본문이 \`return None\` / \`return null\` / \`return\` 한 줄뿐인 함수 (의도된 stub 아닌 한)
|
||||||
|
- 본문이 주석/TODO/FIXME/placeholder 텍스트뿐인 함수
|
||||||
|
- 클래스 정의 안이 \`pass\` 뿐
|
||||||
|
- import만 있고 *실제 로직이 한 줄도 없는 모듈*
|
||||||
|
- "여기에 X를 구현하세요" 같은 자리표시 문자열
|
||||||
|
- 함수 시그니처만 있고 본문이 \`...\` 뿐
|
||||||
|
|
||||||
|
"완료했습니다"라고 말하기 전에 *생성한 모든 파일의 내용*을 다시 보고 위 패턴이 없는지 확인하세요.
|
||||||
|
진짜로 stub이 *의도된* 경우(예: 인터페이스 정의)는 \`# stub: 이유\` 주석으로 명시해야 합니다.
|
||||||
|
사용자는 빈 깡통 파일을 가장 싫어합니다 — 완성된 로직이 들어가야 답변이 끝납니다.
|
||||||
|
|
||||||
|
체크 결과는 [Self-Reflector Check]의 References / Paths 줄에 한 줄로 요약하고, 빈 깡통 의심 항목이 있다면 거기에 명시하세요.
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시스템 프롬프트 끝에 self-reflector 룰을 *조건부로* 추가하는 헬퍼.
|
||||||
|
*
|
||||||
|
* @param baseSystem 원본 시스템 프롬프트
|
||||||
|
* @param opts.enabled 사용자 설정 — false면 원본 그대로 반환 (룰 미추가)
|
||||||
|
* @param opts.includeCodeGuard true면 코드 가드 블록도 추가 (developer 직군 등)
|
||||||
|
*/
|
||||||
|
export function appendSelfReflectorRule(
|
||||||
|
baseSystem: string,
|
||||||
|
opts: { enabled: boolean; includeCodeGuard?: boolean } = { enabled: true },
|
||||||
|
): string {
|
||||||
|
if (!opts.enabled) return baseSystem;
|
||||||
|
const parts = [baseSystem.trimEnd()];
|
||||||
|
if (opts.includeCodeGuard) parts.push('', SELF_REFLECTOR_CODE_GUARD);
|
||||||
|
parts.push('', SELF_REFLECTOR_RULE_BLOCK);
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Self-Reflector Phase B — *분리된 콘텍스트*에서 LLM 한 번 더 호출해 응답을
|
||||||
|
* 외부 시각으로 검증.
|
||||||
|
*
|
||||||
|
* Phase A의 self-check는 같은 모델·같은 콘텍스트에서 자기 자신을 보는 한계가
|
||||||
|
* 있다. 모델이 자기가 만든 답변을 자신 있게 잘못 평가하는 *과신 편향*은
|
||||||
|
* LLM의 잘 알려진 약점이다. Phase B는 이걸 보완하기 위해:
|
||||||
|
*
|
||||||
|
* 1. specialist 응답이 끝나면
|
||||||
|
* 2. *새로운* system prompt로 LLM에게 (task + 응답)을 보여주고
|
||||||
|
* 3. "이 응답이 task를 충실히 처리했나? 명백한 오류가 있나?"를 묻는다
|
||||||
|
* 4. {verdict: pass|warn|fail, issues: [...]} JSON으로 받음
|
||||||
|
* 5. fail이면 issue들을 prepend해 같은 specialist 1회 retry
|
||||||
|
*
|
||||||
|
* 호출자(dispatcher)는 verdict + issues + final response를 받아 그대로 chat에
|
||||||
|
* 표시한다. 검증 LLM 자체가 실패해도 *원본 응답은 보존* — 검증 layer가
|
||||||
|
* 진행 자체를 막지 않는다.
|
||||||
|
*/
|
||||||
|
import { IAIService } from '../../core/services';
|
||||||
|
import { logError, logInfo } from '../../utils';
|
||||||
|
|
||||||
|
export interface VerifyInput {
|
||||||
|
/** specialist에게 줬던 task 문자열 (revisionNote 등 prefix 포함). */
|
||||||
|
task: string;
|
||||||
|
/** specialist의 raw 응답 (action-tag 실행 *전*). */
|
||||||
|
response: string;
|
||||||
|
/** specialist가 누구였는지 (검증 프롬프트 컨텍스트). */
|
||||||
|
agentName: string;
|
||||||
|
/** 검증에 사용할 모델. 비싸지 않아도 OK — 검증은 짧고 가볍게. */
|
||||||
|
model?: string;
|
||||||
|
/** Requirement Contract — 있으면 검증 기준으로 직접 활용. */
|
||||||
|
contractBlock?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerifyVerdict = 'pass' | 'warn' | 'fail';
|
||||||
|
|
||||||
|
export interface VerifyResult {
|
||||||
|
verdict: VerifyVerdict;
|
||||||
|
/** 발견된 이슈 목록 (verdict='warn'·'fail' 시 1~3개). */
|
||||||
|
issues: string[];
|
||||||
|
/** 한 줄 요약 — chat label용. */
|
||||||
|
summary: string;
|
||||||
|
/** 검증 LLM이 실패한 경우 true; 호출자는 원본 응답 보존하고 진행. */
|
||||||
|
verifierError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `당신은 *외부 감리* 입니다. 다른 에이전트가 방금 사용자 task에 대해 만든 응답을 객관적으로 점검합니다. 응답을 만든 본인이 아니므로 *과신 없이* 보세요.
|
||||||
|
|
||||||
|
점검 기준:
|
||||||
|
1. task 요구를 *직접* 처리했는가 (회피·동문서답 X)
|
||||||
|
2. 명백한 사실 오류·논리 모순이 없는가
|
||||||
|
3. 코드/파일이 포함됐다면 정의되지 않은 변수·잘못된 경로·존재하지 않는 import가 없는가
|
||||||
|
4. Requirement Contract가 있으면 criteria를 위반하지 않는가
|
||||||
|
|
||||||
|
🛑 **빈 깡통(Hollow Code) 자동 fail**:
|
||||||
|
코드 파일이 포함됐는데 *실제 로직이 비어 있으면* 무조건 "fail":
|
||||||
|
- 함수 본문이 \`pass\` / \`return None\` / \`return null\` / \`...\` 한 줄뿐
|
||||||
|
- 함수 본문이 TODO/FIXME 주석뿐
|
||||||
|
- 클래스/모듈에 import만 있고 로직 0줄
|
||||||
|
- "여기에 X를 구현하세요" 같은 placeholder만 들어 있음
|
||||||
|
이런 패턴은 *문법은 통과해도 사용자가 원한 결과물이 아닙니다*. issues에
|
||||||
|
"빈 깡통: <함수명> 본문이 stub뿐" 처럼 *구체적인 위치*를 적으세요.
|
||||||
|
|
||||||
|
평가 라벨:
|
||||||
|
- "pass" : 위 모든 기준 통과
|
||||||
|
- "warn" : 일부 약점이 있지만 사용자가 받아 볼 만함 (issues에 적기)
|
||||||
|
- "fail" : 핵심 오류·누락 또는 *빈 깡통* 발견 — 재작업 필요 (issues에 적기)
|
||||||
|
|
||||||
|
⚠️ 반드시 아래 JSON 한 번만. 다른 텍스트(설명·코드펜스) 일체 금지.
|
||||||
|
|
||||||
|
{"verdict":"pass"|"warn"|"fail","issues":["<이슈1>","<이슈2>"],"summary":"한 줄(30자 이내)"}`;
|
||||||
|
|
||||||
|
function _buildUserMessage(input: VerifyInput): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (input.contractBlock) {
|
||||||
|
lines.push(input.contractBlock);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push(`[검증 대상 에이전트] ${input.agentName}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[task]');
|
||||||
|
lines.push(input.task.slice(0, 2000));
|
||||||
|
lines.push('');
|
||||||
|
lines.push('[해당 에이전트의 응답]');
|
||||||
|
lines.push((input.response || '').slice(0, 4000));
|
||||||
|
lines.push('');
|
||||||
|
lines.push('점검 JSON만 출력:');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseVerdictJson(raw: string): { verdict: VerifyVerdict; issues: string[]; summary: string } | null {
|
||||||
|
if (!raw || !raw.trim()) return null;
|
||||||
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||||
|
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||||
|
const tryParse = (s: string) => {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(s) as Record<string, unknown>;
|
||||||
|
const v = typeof obj.verdict === 'string' ? obj.verdict.toLowerCase().trim() : '';
|
||||||
|
if (v !== 'pass' && v !== 'warn' && v !== 'fail') return null;
|
||||||
|
const issues = Array.isArray(obj.issues)
|
||||||
|
? obj.issues.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
||||||
|
.map((x) => x.trim()).slice(0, 5)
|
||||||
|
: [];
|
||||||
|
const summary = typeof obj.summary === 'string' ? obj.summary.trim() : '';
|
||||||
|
return { verdict: v as VerifyVerdict, issues, summary };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const direct = tryParse(stage1);
|
||||||
|
if (direct) return direct;
|
||||||
|
const m = stage1.match(/\{[\s\S]*\}/);
|
||||||
|
if (m) {
|
||||||
|
const balanced = tryParse(m[0]);
|
||||||
|
if (balanced) return balanced;
|
||||||
|
}
|
||||||
|
// 최후의 fallback: 정규식으로 verdict만이라도.
|
||||||
|
const verdictMatch = stage1.match(/"verdict"\s*:\s*"(pass|warn|fail)"/i);
|
||||||
|
if (verdictMatch) {
|
||||||
|
return {
|
||||||
|
verdict: verdictMatch[1].toLowerCase() as VerifyVerdict,
|
||||||
|
issues: [],
|
||||||
|
summary: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증 한 회차. 호출 실패 / JSON 파싱 실패는 *통과로 간주* (verifierError=true
|
||||||
|
* 표시). 검증 layer가 작업 진행을 막지 않게 — 본래 의도가 안전망이지 통제관문이
|
||||||
|
* 아니므로.
|
||||||
|
*/
|
||||||
|
export async function verifyResponse(
|
||||||
|
ai: IAIService,
|
||||||
|
input: VerifyInput,
|
||||||
|
): Promise<VerifyResult> {
|
||||||
|
let raw = '';
|
||||||
|
try {
|
||||||
|
const result = await ai.chat({
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
user: _buildUserMessage(input),
|
||||||
|
model: input.model,
|
||||||
|
});
|
||||||
|
raw = result.content || '';
|
||||||
|
} catch (e: any) {
|
||||||
|
logError('selfReflectorVerifier: call failed; treating as pass.', { error: e?.message ?? String(e) });
|
||||||
|
return { verdict: 'pass', issues: [], summary: '검증 실패 — 원본 유지', verifierError: true };
|
||||||
|
}
|
||||||
|
const parsed = _parseVerdictJson(raw);
|
||||||
|
if (!parsed) {
|
||||||
|
logInfo('selfReflectorVerifier: parse failed; treating as pass.', { rawHead: raw.slice(0, 100) });
|
||||||
|
return { verdict: 'pass', issues: [], summary: '검증 응답 파싱 실패 — 원본 유지', verifierError: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
verdict: parsed.verdict,
|
||||||
|
issues: parsed.issues,
|
||||||
|
summary: parsed.summary || `verdict: ${parsed.verdict}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증 결과를 retry 시 사용할 prompt prefix로 직렬화. 호출자가 task 앞에
|
||||||
|
* prepend 해 specialist를 1회 더 호출한다.
|
||||||
|
*/
|
||||||
|
export function formatIssuesForRetry(issues: string[]): string {
|
||||||
|
if (!issues.length) return '';
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('[외부 감리 지적 — 반드시 반영]');
|
||||||
|
for (const i of issues) lines.push(`- ${i}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Knowledge Mix — controls how much the assistant leans on Second Brain
|
* Knowledge Mix — model 지식 vs Second Brain 지식의 *상대 비율*을 LLM에게
|
||||||
* evidence vs. the model's own general knowledge for a given query.
|
* 전달하는 정책 레이어.
|
||||||
*
|
*
|
||||||
* The single integer "secondBrainWeight" (0–100) drives three things:
|
* ── 정책 v2 (상대값+상대값=상대값) ──────────────────────────────────────
|
||||||
|
* weight는 0~100 정수이지만 *상대 비율*로만 해석한다. 즉 "70"은 "100% 중
|
||||||
|
* 70%"라는 상대 표현이고, 시스템이 도중에 *절대 정수*(예: brain 파일 N개)
|
||||||
|
* 로 변환하지 않는다. 절대 변환은 v1의 핵심 약점이었다 — 사용자가 입력한
|
||||||
|
* 상대적 의미가 brain 파일 개수라는 절대 정수로 펴지면서 LLM이 받는
|
||||||
|
* 비율 정보와 retrieve된 실제 양이 따로 놀았다.
|
||||||
*
|
*
|
||||||
* 1. RAG chunk budget — how many brain files we feed the model.
|
* v2에서 weight가 *실제로* 영향을 미치는 곳은 단 한 군데:
|
||||||
* 2. Retrieval ratio — what fraction of the context budget RAG can claim.
|
* - `buildKnowledgeMixPolicy` — LLM 시스템 프롬프트에 "model X% / brain Y%"
|
||||||
* 3. Prompt policy — natural-language instruction injected into the
|
* 자연어 정책을 삽입. 비율 그 자체만 모델에게 전달.
|
||||||
* system prompt telling the model how to balance
|
|
||||||
* its own knowledge against the evidence shown.
|
|
||||||
*
|
*
|
||||||
* Per-agent overrides (AgentKnowledgeEntry.secondBrainWeight) win over the
|
* 절대값 측면(brain 파일 개수, context 예산 비율)은 사용자 설정
|
||||||
* global config (g1nation.knowledgeMix.secondBrainWeight). Both fall back to
|
* (`memoryLongTermFiles`)을 그대로 사용. 두 극단값(weight=0, weight=100)만
|
||||||
* the default `DEFAULT_WEIGHT` (balanced) when nothing is set.
|
* 안전 차원에서 절대 의미를 유지 — 0이면 0개·5% 예산, 100이면 50% 예산.
|
||||||
*
|
*
|
||||||
* Keeping this module isolated and pure makes it trivial to unit-test the
|
* 우선순위: per-agent override → global config → DEFAULT_WEIGHT(50).
|
||||||
* mapping curve and to extend it later (e.g. add a "creative" axis) without
|
|
||||||
* touching retrieval or prompt assembly.
|
|
||||||
*/
|
*/
|
||||||
import { getConfig } from '../config';
|
import { getConfig } from '../config';
|
||||||
import { getOrCreateAgentEntry } from '../skills/agentKnowledgeMap';
|
import { getOrCreateAgentEntry } from '../skills/agentKnowledgeMap';
|
||||||
@@ -73,42 +74,45 @@ function _clamp(n: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a weight to the maximum number of brain files (long-term memory) the
|
* Brain 파일 *최대 개수*를 결정.
|
||||||
* retriever is allowed to consider for this turn.
|
|
||||||
*
|
*
|
||||||
* Curve was chosen so that:
|
* Knowledge Mix v2 정책 (상대값+상대값=상대값):
|
||||||
* - 0 fully disables brain-file retrieval (model-only mode).
|
* weight는 LLM에게 *얼마나 신뢰할지*를 전달하는 상대 비율일 뿐, brain 파일
|
||||||
* - 50 maps to roughly the existing default (`memoryLongTermFiles`), so
|
* *개수 자체*를 좌우하지 않는다. 사용자가 명시적으로 설정한
|
||||||
* behaviour without any per-agent setting matches the status quo.
|
* `memoryLongTermFiles`(=`configuredLimit`)를 그대로 사용.
|
||||||
* - 100 pushes the cap toward `selectWithinBudget`'s hard ceiling (12).
|
|
||||||
*
|
*
|
||||||
* The configured `memoryLongTermFiles` is treated as the "balanced" baseline:
|
* weight가 의미를 가지는 극단값 두 가지만 절대 의미를 유지한다:
|
||||||
* it's scaled up at high weights and damped at low weights.
|
* - weight=0 → 0개 (사용자가 명시적으로 "brain 사용 안 함" 선언)
|
||||||
|
* - 그 외 → configuredLimit 그대로
|
||||||
|
*
|
||||||
|
* 이전 v1은 weight=70이면 8개, weight=30이면 4개 식으로 절대 정수 변환을 했고
|
||||||
|
* 이게 "사용자가 의도한 70%라는 상대 비율"의 의미를 도중에 잃게 만드는
|
||||||
|
* 원인이었다. 비율 표현은 `buildKnowledgeMixPolicy`가 LLM에게 자연어로
|
||||||
|
* 전달하는 역할 하나만 맡는다.
|
||||||
*/
|
*/
|
||||||
export function mapWeightToBrainFileLimit(weight: number, configuredLimit: number): number {
|
export function mapWeightToBrainFileLimit(weight: number, configuredLimit: number): number {
|
||||||
const w = _clamp(weight);
|
const w = _clamp(weight);
|
||||||
if (w === 0) return 0;
|
if (w === 0) return 0;
|
||||||
const baseline = Math.max(1, configuredLimit || 6);
|
const baseline = Math.max(1, configuredLimit || 6);
|
||||||
// Linear interpolation:
|
// 안전 상한 12는 그대로 — context budget 폭주 방지. 그 외엔 사용자 설정 그대로.
|
||||||
// w=0 → 0
|
return Math.max(0, Math.min(12, baseline));
|
||||||
// w=25 → baseline * 0.5
|
|
||||||
// w=50 → baseline
|
|
||||||
// w=75 → baseline * 1.5
|
|
||||||
// w=100 → baseline * 2 (capped at 12 elsewhere)
|
|
||||||
const scaled = Math.round((w / 50) * baseline);
|
|
||||||
// Honour the orchestrator's hard cap (12) so we never blow the budget.
|
|
||||||
return Math.max(0, Math.min(12, scaled));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a weight to the retrieval ratio (fraction of the context-budget that
|
* Brain retrieval에 할당할 context-budget 비율.
|
||||||
* RAG can claim). Lower weights mean RAG gets a smaller slice and leaves more
|
*
|
||||||
* room for conversation history / system prompt.
|
* Knowledge Mix v2 정책: weight와 *분리*. budget 분배는 시스템 안정성에 관한
|
||||||
|
* 절대 결정이지 사용자가 입력한 상대 비율이 직접 좌우할 일이 아니다. 다만
|
||||||
|
* 두 극단값에서만 의미를 유지:
|
||||||
|
* - weight=0 → 0.05 (검색 자체가 비활성화되어도 다른 컨텍스트는 살림)
|
||||||
|
* - weight=100 → 0.50 (brain이 거의 유일한 근거일 때 더 큰 슬라이스)
|
||||||
|
* - 그 외 → 0.40 (균형 baseline)
|
||||||
*/
|
*/
|
||||||
export function mapWeightToRetrievalRatio(weight: number): number {
|
export function mapWeightToRetrievalRatio(weight: number): number {
|
||||||
const w = _clamp(weight);
|
const w = _clamp(weight);
|
||||||
// 0 → 0.05 (still room for tiny lessons block), 50 → 0.40 (status quo), 100 → 0.60.
|
if (w === 0) return 0.05;
|
||||||
return Math.max(0.05, Math.min(0.6, 0.05 + (w / 100) * 0.55));
|
if (w === 100) return 0.5;
|
||||||
|
return 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+180
-5
@@ -19,12 +19,153 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
|||||||
provider._lmStudio?.activity.bump();
|
provider._lmStudio?.activity.bump();
|
||||||
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
|
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
|
||||||
// ── 1인 기업 모드 우선 분기 ──
|
// ── 1인 기업 모드 우선 분기 ──
|
||||||
// When company mode is active, route the prompt through the
|
// 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜
|
||||||
// CEO planner / sequential dispatcher / synthesis pipeline
|
// (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅
|
||||||
// instead of the normal single-agent path. The user-facing
|
// 경로 + followup 라벨, (c) 신규 업무 → 풀 파이프라인 dispatch.
|
||||||
// chat surface is the same — only the runtime differs.
|
// classifier가 비활성화돼 있거나 호출 실패 시엔 안전하게 new_task로
|
||||||
|
// 폴백 — 즉 사용자가 의도한 작업 요청을 놓치는 일은 절대 없다.
|
||||||
if (provider.isCompanyModeEnabled() && typeof data.value === 'string' && data.value.trim()) {
|
if (provider.isCompanyModeEnabled() && typeof data.value === 'string' && data.value.trim()) {
|
||||||
await provider._runCompanyTurn(data.value.trim());
|
let userPrompt = data.value.trim();
|
||||||
|
const { getConfig } = await import('../config');
|
||||||
|
const cfg = getConfig();
|
||||||
|
const { readCompanyState, resolveActivePipeline } = await import('../features/company');
|
||||||
|
const state = readCompanyState(provider._context);
|
||||||
|
|
||||||
|
// ── alignment 답변 라우팅 ──
|
||||||
|
// 사용자가 이전 메시지에서 alignment 카드를 받아 답변하는 중이면
|
||||||
|
// 이 메시지를 분류기/dispatch가 아니라 alignment 답변 핸들러로
|
||||||
|
// 보낸다. 답변이 처리되면서 자동으로 다음 라운드 또는 pipeline
|
||||||
|
// 으로 진행됨.
|
||||||
|
if (provider.isAlignmentPending()) {
|
||||||
|
await provider._handleAlignmentAnswer(userPrompt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 사용자 키워드 override ──
|
||||||
|
// 입력 맨 앞에 `[파이프라인:id]` 또는 `[pipeline:id]`가 있으면
|
||||||
|
// 분류기 무관하게 그 파이프라인 강제 + 그 키워드는 prompt에서
|
||||||
|
// 제거 후 dispatcher에 전달. id가 유효하지 않으면 무시(분류기 정상 경로).
|
||||||
|
let keywordOverrideId: string | undefined;
|
||||||
|
const keywordMatch = userPrompt.match(/^\s*\[(?:파이프라인|pipeline)\s*:\s*([a-z0-9_-]+)\s*\]\s*/i);
|
||||||
|
if (keywordMatch) {
|
||||||
|
const id = keywordMatch[1].toLowerCase();
|
||||||
|
if (state.pipelines?.[id]) {
|
||||||
|
keywordOverrideId = id;
|
||||||
|
userPrompt = userPrompt.slice(keywordMatch[0].length).trim() || userPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── alignment bypass 키워드 ──
|
||||||
|
// 입력 맨 앞 `[건너뛰기]` 또는 `[skip]` → alignment 단계 1회 우회.
|
||||||
|
// 사용자가 "지금은 빨리 가자"라고 명시한 경우에만 사용. prompt에서
|
||||||
|
// 키워드 제거.
|
||||||
|
let alignmentBypass = false;
|
||||||
|
const bypassMatch = userPrompt.match(/^\s*\[(?:건너뛰기|skip)\]\s*/i);
|
||||||
|
if (bypassMatch) {
|
||||||
|
alignmentBypass = true;
|
||||||
|
userPrompt = userPrompt.slice(bypassMatch[0].length).trim() || userPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.companyDisableIntentClassifier) {
|
||||||
|
// 분류기 우회 모드 — 분류 단계는 건너뛰지만 alignment는 별도로
|
||||||
|
// 작동(사용자가 alignment off로 설정하지 않은 한). 분류기 끄는
|
||||||
|
// 이유는 보통 "잡담도 다 pipeline으로"인데 그럴수록 alignment
|
||||||
|
// 효과가 큼.
|
||||||
|
try { provider.pixelOfficeOnIntentClassified('new_task', userPrompt); } catch { /* noop */ }
|
||||||
|
if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) {
|
||||||
|
await provider._runCompanyTurn(userPrompt, undefined, keywordOverrideId);
|
||||||
|
} else {
|
||||||
|
await provider._runIntentAlignment({
|
||||||
|
userPrompt,
|
||||||
|
pipelineIdOverride: keywordOverrideId,
|
||||||
|
mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart',
|
||||||
|
roundsLimit: cfg.companyIntentAlignmentMaxRounds,
|
||||||
|
roundsAsked: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const { classifyChatIntent } = await import('../features/company');
|
||||||
|
const { AIService } = await import('../core/services');
|
||||||
|
const last = provider.getLastCompanyTurnSummary();
|
||||||
|
const activePipeline = resolveActivePipeline(state);
|
||||||
|
// 사용 가능한 모든 파이프라인을 분류기 후보로 전달 — 단, 활성화돼
|
||||||
|
// 있어야 추천 의미가 있는 게 아니라 *정의돼 있기만 하면* 후보. 사용자가
|
||||||
|
// 평소엔 짧은 걸 활성화해 두고 가끔 풀 사이클 의도가 명확한 발화를
|
||||||
|
// 했을 때 분류기가 그쪽을 추천할 수 있게.
|
||||||
|
const allPipelines = Object.values(state.pipelines ?? {});
|
||||||
|
const verdict = await classifyChatIntent(
|
||||||
|
new AIService(),
|
||||||
|
userPrompt,
|
||||||
|
{
|
||||||
|
previousBrief: last?.brief,
|
||||||
|
previousReportTail: last?.reportTail,
|
||||||
|
previousTurnAt: last?.finishedAt,
|
||||||
|
activePipelineName: activePipeline?.name,
|
||||||
|
availablePipelines: allPipelines.length > 0
|
||||||
|
? allPipelines.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
stageCount: p.stages.length,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
|
||||||
|
);
|
||||||
|
// Pixel Office: 분류 결과를 UI layer로만 흘림. 아래 분기 자체엔 영향 없음.
|
||||||
|
try { provider.pixelOfficeOnIntentClassified(verdict.intent, userPrompt); } catch { /* noop */ }
|
||||||
|
if (verdict.intent === 'new_task') {
|
||||||
|
// 우선순위: (1) 사용자 키워드 (2) autoSelect가 켜져 있고 분류기 추천 있음 (3) 사용자 활성 파이프라인.
|
||||||
|
let effectiveOverride = keywordOverrideId;
|
||||||
|
if (!effectiveOverride && cfg.companyAutoSelectPipeline && verdict.suggestedPipelineId) {
|
||||||
|
effectiveOverride = verdict.suggestedPipelineId;
|
||||||
|
}
|
||||||
|
// 분류기가 추천을 냈지만 autoSelect가 꺼져 있을 땐 라벨로만 안내.
|
||||||
|
if (verdict.suggestedPipelineId && !effectiveOverride && !cfg.companyAutoSelectPipeline) {
|
||||||
|
const tip = state.pipelines?.[verdict.suggestedPipelineId];
|
||||||
|
if (tip) {
|
||||||
|
provider._view?.webview.postMessage({
|
||||||
|
type: 'companyIntentDecision',
|
||||||
|
value: {
|
||||||
|
intent: 'new_task',
|
||||||
|
reason: `🧭 추천 파이프라인: "${tip.name}" (자동 적용은 설정 토글)`,
|
||||||
|
label: '🛠️ 신규 업무',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (effectiveOverride && effectiveOverride !== state.activePipelineId) {
|
||||||
|
const used = state.pipelines?.[effectiveOverride];
|
||||||
|
if (used) {
|
||||||
|
provider._view?.webview.postMessage({
|
||||||
|
type: 'companyIntentDecision',
|
||||||
|
value: {
|
||||||
|
intent: 'new_task',
|
||||||
|
reason: keywordOverrideId
|
||||||
|
? `🔧 키워드 override → "${used.name}"`
|
||||||
|
: `🧭 CEO 자동 선택 → "${used.name}"`,
|
||||||
|
label: '🛠️ 신규 업무',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ── Intent Alignment 진입 ──
|
||||||
|
// off 모드이거나 bypass 키워드가 있으면 alignment 우회하고
|
||||||
|
// legacy 동작 (즉시 dispatch). 그 외엔 분석기 1라운드 돌려
|
||||||
|
// confidence에 따라 자동 진행 또는 카드 표시.
|
||||||
|
if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) {
|
||||||
|
await provider._runCompanyTurn(userPrompt, undefined, effectiveOverride);
|
||||||
|
} else {
|
||||||
|
await provider._runIntentAlignment({
|
||||||
|
userPrompt,
|
||||||
|
pipelineIdOverride: effectiveOverride,
|
||||||
|
mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart',
|
||||||
|
roundsLimit: cfg.companyIntentAlignmentMaxRounds,
|
||||||
|
roundsAsked: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await provider._handleCompanyCasual(userPrompt, verdict.intent, verdict.reason, data);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await provider._handlePrompt(data);
|
await provider._handlePrompt(data);
|
||||||
@@ -48,6 +189,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
|||||||
// Restore the Company chip from globalState so the user sees the same
|
// Restore the Company chip from globalState so the user sees the same
|
||||||
// mode they had on at last shutdown.
|
// mode they had on at last shutdown.
|
||||||
await provider._sendCompanyStatus();
|
await provider._sendCompanyStatus();
|
||||||
|
// Pixel Office — 첫 로드 시 빈 idle 상태라도 한 번 push해서 webview가
|
||||||
|
// 영역 자체를 그릴 수 있게.
|
||||||
|
provider.pixelOfficeResend();
|
||||||
return true;
|
return true;
|
||||||
case 'getReadyStatus':
|
case 'getReadyStatus':
|
||||||
await provider._sendReadyStatus();
|
await provider._sendReadyStatus();
|
||||||
@@ -71,6 +215,10 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
|||||||
await provider._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
|
await provider._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
|
||||||
await provider._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
|
await provider._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
|
||||||
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
|
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
|
||||||
|
// 직전 회사 turn 컨텍스트 캐시 비우기 — 새 세션은 followup 기준점이 없다.
|
||||||
|
provider.clearLastCompanyTurnSummary();
|
||||||
|
// 진행 중이던 alignment도 새 세션과 함께 폐기.
|
||||||
|
provider.cancelPendingAlignment();
|
||||||
provider.clearChat();
|
provider.clearChat();
|
||||||
await provider._sendBrainStatus();
|
await provider._sendBrainStatus();
|
||||||
return true;
|
return true;
|
||||||
@@ -78,9 +226,17 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
|||||||
// 1인 기업 모드는 AgentExecutor를 거치지 않으므로 별도 abort 경로.
|
// 1인 기업 모드는 AgentExecutor를 거치지 않으므로 별도 abort 경로.
|
||||||
// 두 경로 모두 신호를 보내 두면 중간에 모드 전환되어도 안전.
|
// 두 경로 모두 신호를 보내 두면 중간에 모드 전환되어도 안전.
|
||||||
provider.abortCompanyTurn();
|
provider.abortCompanyTurn();
|
||||||
|
// 진행 중인 Intent Alignment도 같이 정리 — 사용자가 Stop 누르면
|
||||||
|
// 의도상 모든 대기 상태 해제.
|
||||||
|
provider.cancelPendingAlignment();
|
||||||
provider._agent.stop();
|
provider._agent.stop();
|
||||||
return true;
|
return true;
|
||||||
case 'loadSession':
|
case 'loadSession':
|
||||||
|
// 세션 전환 시 직전 회사 turn 캐시 무효화 — 로드된 세션의 직전 회사
|
||||||
|
// 작업은 별도 파일에서 복원되어야 하지 메모리 캐시에 남은 다른
|
||||||
|
// 세션의 보고서가 새 세션 첫 메시지의 followup 판정을 오염시키면 안 됨.
|
||||||
|
provider.clearLastCompanyTurnSummary();
|
||||||
|
provider.cancelPendingAlignment();
|
||||||
await provider._loadSession(data.id);
|
await provider._loadSession(data.id);
|
||||||
return true;
|
return true;
|
||||||
case 'deleteSession':
|
case 'deleteSession':
|
||||||
@@ -401,6 +557,25 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
|
|||||||
});
|
});
|
||||||
return true;
|
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': {
|
case 'respondCompanyApproval': {
|
||||||
// Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제.
|
// Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제.
|
||||||
// payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? }
|
// payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? }
|
||||||
|
|||||||
+1172
-1
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -262,7 +262,18 @@ export function getSystemPrompt(): string {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
||||||
const isoDate = now.toISOString().split('T')[0];
|
const isoDate = now.toISOString().split('T')[0];
|
||||||
return `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`;
|
const base = `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`;
|
||||||
|
// Self-Reflector Phase A — 사용자 설정이 켜져 있으면 답변 끝에 자기검증
|
||||||
|
// 블록을 강제하는 룰을 prepend. require로 동적 로드해 순환 import 회피.
|
||||||
|
try {
|
||||||
|
const { getConfig } = require('./config') as typeof import('./config');
|
||||||
|
const { appendSelfReflectorRule } = require('./features/selfReflector/selfReflectorPrompt') as typeof import('./features/selfReflector/selfReflectorPrompt');
|
||||||
|
const cfg = getConfig();
|
||||||
|
return appendSelfReflectorRule(base, { enabled: cfg.selfReflectorEnabled });
|
||||||
|
} catch {
|
||||||
|
// config 로드 실패 시(테스트 환경 등)는 룰 없이 원본 그대로.
|
||||||
|
return base;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
|
export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
|
||||||
|
|||||||
Reference in New Issue
Block a user