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:
2026-05-15 14:16:14 +09:00
parent ed7e497194
commit 72412450c3
33 changed files with 4964 additions and 125 deletions
+25
View File
@@ -1152,6 +1152,31 @@ export class AgentExecutor {
this.statusBarManager.updateStatus(AgentStatus.Executing);
// Action tags are honored only from the visible final answer — never from hidden reasoning.
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) {
const promptCharCount = messagesForRequest.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
logError('Model returned an empty response without actions.', {
+74
View File
@@ -72,6 +72,67 @@ export interface IAgentConfig {
* true(기본): Reflector가 plan/research를 비판적으로 검토한 critique을 Writer에 주입.
* 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;
/**
* [Self-Reflection → Knowledge] Reflector critique 중 의미 있는 발견을 brain의
@@ -166,6 +227,19 @@ export function getConfig(): IAgentConfig {
knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round(
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),
autoLessonFromReflection: cfg.get<boolean>('autoLessonFromReflection', true),
};
+5
View File
@@ -659,6 +659,11 @@ export async function activate(context: vscode.ExtensionContext) {
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
+8 -5
View File
@@ -227,12 +227,15 @@ export async function runCeoPlanner(
ai: IAIService,
userPrompt: string,
state: CompanyState,
options: { model?: string; timeoutMs?: number } = {},
options: { model?: string; timeoutMs?: number; contractBlock?: string } = {},
): Promise<PlannerResult> {
const system = buildPlannerSystemPrompt(
applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName }),
state,
);
const baseSystem = applyPromptVars(CEO_PLANNER_PROMPT, { company: state.companyName });
// Contract가 있으면 planner 시스템 프롬프트 끝에 prepend. planner는 task
// 리스트를 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 = '';
try {
const result = await ai.chat({
+24 -5
View File
@@ -112,21 +112,40 @@ function _normalizeStage(raw: unknown): PipelineStage | null {
const r = raw as Record<string, unknown>;
const id = typeof r.id === 'string' ? r.id.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;
if (!_validId(id) || !agentId) return null;
if (!_validId(id)) return null;
// agentId 또는 roleCategory 둘 중 하나는 반드시 있어야 한다.
// 둘 다 없으면 dispatcher가 누구를 부를지 알 길이 없어 stage가 의미 없음.
if (!agentId && !roleCategory) return null;
const out: PipelineStage = {
id, label, agentId,
id, label,
instructionTemplate: typeof r.instructionTemplate === 'string' ? r.instructionTemplate : '',
};
if (typeof r.roleCategory === 'string' && VALID_ROLE_CATEGORIES.has(r.roleCategory as AgentRoleCategory)) {
out.roleCategory = r.roleCategory;
}
if (agentId) out.agentId = agentId;
if (roleCategory) out.roleCategory = roleCategory;
if (typeof r.modelOverride === 'string' && r.modelOverride.trim()) {
out.modelOverride = r.modelOverride.trim();
}
if (r.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()) {
out.loopBackPattern = r.loopBackPattern.trim();
}
+556 -11
View File
@@ -40,6 +40,7 @@ import {
buildKnowledgeMixPolicy,
} from '../../retrieval/knowledgeMix';
import {
listActiveAgentsByCategory,
modelForAgent, readCompanyState, resolveActivePipeline, resolveAgent, resolveCompanyKnowledgeMix,
} from './companyConfig';
import { runCeoPlanner } from './ceoPlanner';
@@ -64,7 +65,11 @@ import {
writeResumeState,
} from './resumeStore';
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. */
const PEER_OUTPUT_BUDGET = 1500;
@@ -105,6 +110,28 @@ export type CompanyTurnEvent =
| { phase: 'awaiting-approval'; stageId: string; stageLabel: string; index: number; total: number }
/** Resolved approval — purely informational for the chat log. */
| { 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-done'; report: string; ok: boolean }
/**
@@ -160,6 +187,22 @@ export interface DispatcherDeps {
* (so the dispatcher doesn't hang forever)
*/
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: '' });
} else {
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) {
// Pipeline mode: the user has authored a fixed sequence of stages.
// We still surface a `plan` for the report writer and the session
// summary — derived directly from the pipeline definition.
plan = {
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 {
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;
plannerRaw = plannerResult.raw;
plannerParsed = plannerResult.parsed;
@@ -568,6 +627,11 @@ async function _dispatchOne(
peerOutputs,
brainContext, // injected as `[SECOND BRAIN CONTEXT]` 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.
const model = (stageModelOverride && stageModelOverride.trim())
@@ -580,7 +644,62 @@ async function _dispatchOne(
user: task,
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>`,
// `<run_command>`, `<edit_file>`, etc. emitted by the agent actually
// hit disk / shell. The report (e.g. "✅ Created: foo.py") is
@@ -592,8 +711,93 @@ async function _dispatchOne(
try {
const report = await deps.executeActionTags(rawResponse);
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) {
// 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
// the warning verbatim.
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 {
agentId, task,
response: finalResponse,
@@ -663,6 +875,282 @@ interface PipelineSeed {
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. */
export interface PipelineCommit {
outputs: AgentTurnOutput[];
@@ -740,20 +1228,77 @@ async function _runPipeline(
const task = note
? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
: baseTask;
emit({ phase: 'agent-start', agentId: stage.agentId, task, index: stepIndex, total });
const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps, stage.modelOverride);
// 동적 담당자 해결. stage.agentId가 박혀 있으면 그걸 쓰고, 비어 있으면
// 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);
latestByStage[stage.id] = turn;
writeAgentOutput(sessionDir, turn);
appendAgentMemory(
deps.context, stage.agentId,
deps.context, resolvedAgentId,
`[${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++;
// Successful run consumed the revision note (if any) — clear it.
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 ──
// After agent-done emits, before loop-back / next stage advance,
// give the user a chance to inspect and approve. We only fire the
+15
View File
@@ -89,3 +89,18 @@ export {
listSessions,
resolveCompanyBase,
} 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';
+334
View File
@@ -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');
}
+348
View File
@@ -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,
};
}
+51 -12
View File
@@ -66,10 +66,12 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
suggestedPipelineId: 'product-dev',
suggestedPipelineName: '제품 개발 파이프라인',
stages: [
// 모든 stage가 *직군*만 지정하고 담당자는 비워둠 (agentId 생략). dispatcher가
// stage 진입 시 CEO에게 1회 LLM 콜로 적임자 선택. 활성 후보가 1명뿐이면
// 콜 없이 그 사람을 쓴다. 사용자의 의도("CEO가 배분 결정")와 일치.
{
id: 'plan-discuss',
label: '기획 논의',
agentId: 'writer',
roleCategory: 'planner',
instructionTemplate:
'사용자 요청: {{userPrompt}}\n\n' +
@@ -79,7 +81,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'market-research',
label: '시장 조사',
agentId: 'researcher',
roleCategory: 'researcher',
instructionTemplate:
'기획 논의 정리: {{stage.plan-discuss}}\n\n' +
@@ -89,7 +90,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'trend-research',
label: '트렌드 조사',
agentId: 'researcher',
roleCategory: 'researcher',
instructionTemplate:
'기획 논의: {{stage.plan-discuss}}\n시장 조사 결과: {{stage.market-research}}\n\n' +
@@ -99,7 +99,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'direction',
label: '방향성 정의',
agentId: 'writer',
roleCategory: 'planner',
instructionTemplate:
'기획 논의: {{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',
label: '기획문서 초안',
agentId: 'writer',
roleCategory: 'planner',
instructionTemplate:
'방향성: {{stage.direction}}\n\n' +
@@ -121,7 +119,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'plan-review',
label: '기획문서 검토',
agentId: 'inspector',
roleCategory: 'inspector',
instructionTemplate:
'검토 대상: {{stage.plan-draft}}\n\n' +
@@ -136,7 +133,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'plan-final',
label: '기획문서 최종본',
agentId: 'writer',
roleCategory: 'planner',
instructionTemplate:
'초안: {{stage.plan-draft}}\n검토 피드백: {{stage.plan-review}}\n\n' +
@@ -145,7 +141,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'dev-design',
label: '개발 설계',
agentId: 'developer',
roleCategory: 'developer',
instructionTemplate:
'최종 기획서: {{stage.plan-final}}\n\n' +
@@ -155,7 +150,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'design-review',
label: '설계 검토',
agentId: 'inspector',
roleCategory: 'inspector',
instructionTemplate:
'설계 문서: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' +
@@ -168,7 +162,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'dev-impl',
label: '개발 진행',
agentId: 'developer',
roleCategory: 'developer',
instructionTemplate:
'설계: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' +
@@ -178,7 +171,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'qa',
label: 'QA 진행',
agentId: 'qa',
roleCategory: 'qa',
instructionTemplate:
'구현 결과: {{stage.dev-impl}}\n기획서: {{stage.plan-final}}\n\n' +
@@ -191,7 +183,6 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
{
id: 'deploy',
label: '라이브 배포',
agentId: 'developer',
roleCategory: 'developer',
instructionTemplate:
'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. */
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
FULL_PRODUCT_DEV,
PLAN_ONLY,
];
export function getPipelineTemplate(id: string): PipelineTemplate | undefined {
+280
View File
@@ -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,
};
}
+29
View File
@@ -47,6 +47,12 @@ export interface SpecialistPromptInputs {
* Tells the specialist how heavily to rely on the brain context.
*/
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);
}
// ── Requirement Contract (Intent Alignment) ──
// alignment 단계를 거쳤다면 사용자와 합의된 contract가 모든 룰 위에 온다.
// 어떤 페르소나·검색 컨텍스트보다도 우선이라는 신호로 출력 규칙 *앞*에 prepend.
if (inputs.contractBlock && inputs.contractBlock.trim()) {
parts.push('');
parts.push(inputs.contractBlock.trim());
}
// ── Output contract ──
parts.push('');
parts.push('## 출력 규칙');
@@ -174,6 +188,21 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
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');
}
+66 -7
View File
@@ -218,14 +218,19 @@ export interface PipelineStage {
id: string;
/** Human label shown in the chat phase header and the editor. */
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
* correct 직군 dropdown without having to re-derive it from agentId
* (handy when the user later changes the agent's 직군 override). The
* dispatcher itself doesn't read this — it goes straight from agentId
* to `resolveAgent`.
* 명시적으로 지정한 담당 에이전트. 비어 있거나 누락이면 dispatcher가
* stage 진입 직전 CEO에게 "이 직군 중 누가 적합?" 한 줄 LLM 콜로
* 결정 — 활성 에이전트가 1명뿐이면 콜 생략하고 그 사람 사용. 매번
* 직군 후보 중에서 CEO가 고르는 게 사용자의 의도(*"CEO가 배분할
* 에이전트를 판단"*)와 일치하므로 기본 권장값은 빈 문자열이다.
*/
agentId?: string;
/**
* 직군 — *동적 담당자 선택의 핵심 필드*. agentId가 비어 있을 때
* dispatcher는 이 직군 안에서만 후보를 추리고 CEO에게 고르게 한다.
* agentId가 있어도 UI가 직군 dropdown을 원래 위치로 복원할 수 있게
* 같이 저장 — 그 경우 dispatcher는 agentId가 우선이므로 무시.
*/
roleCategory?: string;
/**
@@ -245,6 +250,25 @@ export interface PipelineStage {
* to "aborted" cleanly.
*/
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:
* - `{{userPrompt}}` — what the user typed
@@ -271,6 +295,41 @@ export interface PipelineDef {
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. */
export interface CompanyTaskPlan {
/** 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');
}
+41 -37
View File
@@ -1,22 +1,23 @@
/**
* Knowledge Mix — controls how much the assistant leans on Second Brain
* evidence vs. the model's own general knowledge for a given query.
* Knowledge Mix — model 지식 vs Second Brain 지식의 *상대 비율*을 LLM에게
* 전달하는 정책 레이어.
*
* The single integer "secondBrainWeight" (0100) 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.
* 2. Retrieval ratio — what fraction of the context budget RAG can claim.
* 3. Prompt policy — natural-language instruction injected into the
* system prompt telling the model how to balance
* its own knowledge against the evidence shown.
* v2에서 weight가 *실제로* 영향을 미치는 곳은 단 한 군데:
* - `buildKnowledgeMixPolicy` — LLM 시스템 프롬프트에 "model X% / brain Y%"
* 자연어 정책을 삽입. 비율 그 자체만 모델에게 전달.
*
* Per-agent overrides (AgentKnowledgeEntry.secondBrainWeight) win over the
* global config (g1nation.knowledgeMix.secondBrainWeight). Both fall back to
* the default `DEFAULT_WEIGHT` (balanced) when nothing is set.
* 절대값 측면(brain 파일 개수, context 예산 비율)은 사용자 설정
* (`memoryLongTermFiles`)을 그대로 사용. 두 극단값(weight=0, weight=100)만
* 안전 차원에서 절대 의미를 유지 — 0이면 0개·5% 예산, 100이면 50% 예산.
*
* Keeping this module isolated and pure makes it trivial to unit-test the
* mapping curve and to extend it later (e.g. add a "creative" axis) without
* touching retrieval or prompt assembly.
* 우선순위: per-agent override → global config → DEFAULT_WEIGHT(50).
*/
import { getConfig } from '../config';
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
* retriever is allowed to consider for this turn.
* Brain 파일 *최대 개수*를 결정.
*
* Curve was chosen so that:
* - 0 fully disables brain-file retrieval (model-only mode).
* - 50 maps to roughly the existing default (`memoryLongTermFiles`), so
* behaviour without any per-agent setting matches the status quo.
* - 100 pushes the cap toward `selectWithinBudget`'s hard ceiling (12).
* Knowledge Mix v2 정책 (상대값+상대값=상대값):
* weight는 LLM에게 *얼마나 신뢰할지*를 전달하는 상대 비율일 뿐, brain 파일
* *개수 자체*를 좌우하지 않는다. 사용자가 명시적으로 설정한
* `memoryLongTermFiles`(=`configuredLimit`)를 그대로 사용.
*
* The configured `memoryLongTermFiles` is treated as the "balanced" baseline:
* it's scaled up at high weights and damped at low weights.
* weight가 의미를 가지는 극단값 두 가지만 절대 의미를 유지한다:
* - weight=0 → 0개 (사용자가 명시적으로 "brain 사용 안 함" 선언)
* - 그 외 → configuredLimit 그대로
*
* 이전 v1은 weight=70이면 8개, weight=30이면 4개 식으로 절대 정수 변환을 했고
* 이게 "사용자가 의도한 70%라는 상대 비율"의 의미를 도중에 잃게 만드는
* 원인이었다. 비율 표현은 `buildKnowledgeMixPolicy`가 LLM에게 자연어로
* 전달하는 역할 하나만 맡는다.
*/
export function mapWeightToBrainFileLimit(weight: number, configuredLimit: number): number {
const w = _clamp(weight);
if (w === 0) return 0;
const baseline = Math.max(1, configuredLimit || 6);
// Linear interpolation:
// w=0 → 0
// 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));
// 안전 상한 12는 그대로 — context budget 폭주 방지. 그 외엔 사용자 설정 그대로.
return Math.max(0, Math.min(12, baseline));
}
/**
* Map a weight to the retrieval ratio (fraction of the context-budget that
* RAG can claim). Lower weights mean RAG gets a smaller slice and leaves more
* room for conversation history / system prompt.
* Brain retrieval에 할당할 context-budget 비율.
*
* Knowledge Mix v2 정책: weight와 *분리*. budget 분배는 시스템 안정성에 관한
* 절대 결정이지 사용자가 입력한 상대 비율이 직접 좌우할 일이 아니다. 다만
* 두 극단값에서만 의미를 유지:
* - weight=0 → 0.05 (검색 자체가 비활성화되어도 다른 컨텍스트는 살림)
* - weight=100 → 0.50 (brain이 거의 유일한 근거일 때 더 큰 슬라이스)
* - 그 외 → 0.40 (균형 baseline)
*/
export function mapWeightToRetrievalRatio(weight: number): number {
const w = _clamp(weight);
// 0 → 0.05 (still room for tiny lessons block), 50 → 0.40 (status quo), 100 → 0.60.
return Math.max(0.05, Math.min(0.6, 0.05 + (w / 100) * 0.55));
if (w === 0) return 0.05;
if (w === 100) return 0.5;
return 0.4;
}
/**
+180 -5
View File
@@ -19,12 +19,153 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
provider._lmStudio?.activity.bump();
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
// ── 1인 기업 모드 우선 분기 ──
// When company mode is active, route the prompt through the
// CEO planner / sequential dispatcher / synthesis pipeline
// instead of the normal single-agent path. The user-facing
// chat surface is the same — only the runtime differs.
// 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜
// (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅
// 경로 + followup 라벨, (c) 신규 업무 → 풀 파이프라인 dispatch.
// classifier가 비활성화돼 있거나 호출 실패 시엔 안전하게 new_task로
// 폴백 — 즉 사용자가 의도한 작업 요청을 놓치는 일은 절대 없다.
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;
}
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
// mode they had on at last shutdown.
await provider._sendCompanyStatus();
// Pixel Office — 첫 로드 시 빈 idle 상태라도 한 번 push해서 webview가
// 영역 자체를 그릴 수 있게.
provider.pixelOfficeResend();
return true;
case 'getReadyStatus':
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.lastVisibleChatStateKey, null);
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
// 직전 회사 turn 컨텍스트 캐시 비우기 — 새 세션은 followup 기준점이 없다.
provider.clearLastCompanyTurnSummary();
// 진행 중이던 alignment도 새 세션과 함께 폐기.
provider.cancelPendingAlignment();
provider.clearChat();
await provider._sendBrainStatus();
return true;
@@ -78,9 +226,17 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
// 1인 기업 모드는 AgentExecutor를 거치지 않으므로 별도 abort 경로.
// 두 경로 모두 신호를 보내 두면 중간에 모드 전환되어도 안전.
provider.abortCompanyTurn();
// 진행 중인 Intent Alignment도 같이 정리 — 사용자가 Stop 누르면
// 의도상 모든 대기 상태 해제.
provider.cancelPendingAlignment();
provider._agent.stop();
return true;
case 'loadSession':
// 세션 전환 시 직전 회사 turn 캐시 무효화 — 로드된 세션의 직전 회사
// 작업은 별도 파일에서 복원되어야 하지 메모리 캐시에 남은 다른
// 세션의 보고서가 새 세션 첫 메시지의 followup 판정을 오염시키면 안 됨.
provider.clearLastCompanyTurnSummary();
provider.cancelPendingAlignment();
await provider._loadSession(data.id);
return true;
case 'deleteSession':
@@ -401,6 +557,25 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
});
return true;
}
case 'getPixelOfficeState':
// webview가 처음 로드되었거나 사용자가 토글 ON 했을 때 캐시된
// 현재 상태를 다시 받기 위한 요청. read-only.
provider.pixelOfficeResend();
return true;
case 'openPixelOfficePanel':
// 사이드바 mini Pixel Office의 ⛶ 버튼 → editor area에 전체보기 panel 열기.
provider.openPixelOfficePanel();
return true;
case 'respondCompanyAlignment': {
// alignment 카드 버튼: 'proceed' = 현 contract로 dispatch, 'cancel' = 폐기.
const decision = typeof data.decision === 'string' ? data.decision : '';
if (decision === 'proceed') {
await provider._proceedWithCurrentAlignment();
} else if (decision === 'cancel') {
provider.cancelPendingAlignment();
}
return true;
}
case 'respondCompanyApproval': {
// Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제.
// payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? }
+1172 -1
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -262,7 +262,18 @@ export function getSystemPrompt(): string {
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 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;