v2.2.299~307: 아키텍처 수렴 + 채팅 정리 + 벤치마킹 강화 + Claude 구독 엔진
- v2.2.299 아키텍처 수렴: coreChat 통일(엔진 휴리스틱 3벌 제거), 기업 모드 검색 오케스트레이터 승격, lib/execUtil(실행 래퍼 6곳·Python 탐지 3벌 단일화), lib/kstSchedule(워처 4개 nowInKst 통합), estimateTokens 통합, 설정 접근 규칙 명문화 - v2.2.300 채팅 화면 정리: LiveReasoningFilter(스트리밍 중 <think>/Harmony 추론 토큰 단위 차단), 확신도·검토요청 footer 기본 숨김(계산·Reflection 은 유지) - v2.2.301 문맥·의도 이해: [답변 전 이해 원칙] 상시 주입, 워크플로우 의도 브리핑, Report QA 루프(규칙 레지스트리+실측치+회귀 게이트, 블로그_v3 개념 이식) - v2.2.302 /benchmark 비즈니스 렌즈(가격·수익·운영)+빌드 프롬프트 모드+QA 연계 - v2.2.303 handoff 모드(측정치 무손실 인수인계 문서)+/claude(Claude Code 터미널 위임) - v2.2.304 이식성: 지식 경로 두뇌-상대 규약(pickWikiDir 상대 해석), 이사 체크리스트 - v2.2.305 Claude 구독 엔진: claude: 프로바이더(CLI 위임, 모델 드롭다운 자동 노출, coreChat 지원 — 워크플로우·QA도 구독 모델 가능) - v2.2.306 Tone Guard: AI 상투어 금지 레지스트리(상담사 화법 실사례 8종+대조 예시) - v2.2.307 /benchmark 레이아웃 골격(sectionRoles 결정론 분석, 롤링 배너 즉답), 파트별 실패 격리, 합성 타임아웃 120→300초 검증: tsc 무오류 + jest 888 통과 + esbuild 정상 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+52
-6
@@ -41,6 +41,7 @@ import {
|
||||
} from './lib/contextBuilders/promptDetection';
|
||||
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
|
||||
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
|
||||
import { LiveReasoningFilter } from './lib/contextBuilders/liveReasoningFilter';
|
||||
import { buildEngineMessageVariants } from './lib/contextBuilders/engineMessages';
|
||||
import { buildMemoryContext as buildMemoryContextFn } from './lib/contextBuilders/memoryContext';
|
||||
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './lib/contextBuilders/projectEvidence';
|
||||
@@ -102,6 +103,7 @@ import {
|
||||
} from './features/secondBrainTrace';
|
||||
import { MemoryManager } from './memory';
|
||||
import { RetrievalOrchestrator } from './retrieval';
|
||||
import { embedQuery } from './retrieval/embeddings';
|
||||
import { isQaRegressionFeedback, findUnaddressedChecklistItems } from './retrieval/lessonHelpers';
|
||||
import { buildKnowledgeMixPolicy, ResolvedKnowledgeMix } from './retrieval/knowledgeMix';
|
||||
import {
|
||||
@@ -358,6 +360,44 @@ export class AgentExecutor {
|
||||
this.restoreLastSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* [코어 수렴] 기업 모드(dispatcher)용 두뇌 컨텍스트 블록 — 경량 scopedBrainRetriever
|
||||
* 대신 메인 오케스트레이터의 전체 검색 경로(임베딩 하이브리드·청크)를 태운다.
|
||||
* 텔레그램 경량 경로는 의존성 최소화를 위해 의도적으로 그대로 둔다.
|
||||
* 반환 포맷은 기존 buildContextBlock 과 동일 — specialist 프롬프트 형식 불변.
|
||||
*/
|
||||
public async retrieveBrainBlockForCompany(query: string, scopeFolders: string[], limit: number): Promise<string> {
|
||||
const config = getConfig();
|
||||
const brain = getActiveBrainProfile();
|
||||
if (!brain?.localBrainPath) return '';
|
||||
let queryEmbedding: number[] | undefined;
|
||||
if (config.embeddingModel) {
|
||||
try {
|
||||
queryEmbedding = await Promise.race([
|
||||
embedQuery(query, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
|
||||
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 4000)),
|
||||
]);
|
||||
} catch { queryEmbedding = undefined; }
|
||||
}
|
||||
const chunks = this.retrievalOrchestrator.retrieveBrainChunksScoped(query, brain, {
|
||||
limit,
|
||||
scopeFolders,
|
||||
queryEmbedding,
|
||||
embeddingModel: config.embeddingModel || undefined,
|
||||
embeddingBlendAlpha: config.embeddingBlendAlpha,
|
||||
chunkLevelRetrieval: config.chunkLevelRetrieval === true,
|
||||
chunkTargetChars: config.chunkTargetChars,
|
||||
});
|
||||
if (chunks.length === 0) return '';
|
||||
const header = scopeFolders.length > 0
|
||||
? '[제2뇌 컨텍스트 — 매핑된 지식 폴더에서 검색]'
|
||||
: '[제2뇌 컨텍스트 — 전체 브레인 검색]';
|
||||
const body = chunks
|
||||
.map((c, i) => `(#${i + 1}) ${c.title}\n${c.content}`)
|
||||
.join('\n\n---\n\n');
|
||||
return `${header}\n\n${body}`;
|
||||
}
|
||||
|
||||
private async restoreLastSession() {
|
||||
return restoreLastSessionFn({
|
||||
sessionManager: this.sessionManager,
|
||||
@@ -946,10 +986,16 @@ export class AgentExecutor {
|
||||
// policy enforcement) emits a final `streamReplace` so the bubble
|
||||
// ends up matching the cleaned answer regardless of what slipped
|
||||
// through live.
|
||||
// [Clean Stream] g1nation.liveStreamTokens=false (기본) 이면 토큰을 내부에만
|
||||
// 누적하고 sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커가 잠깐
|
||||
// 화면에 노출되는 누설을 원천 차단한다. true 로 두면 legacy 라이브 스트리밍.
|
||||
// [Clean Stream] liveStreamTokens=true(기본) 면 토큰을 실시간 표시하되,
|
||||
// LiveReasoningFilter 가 <think>/<|channel|>thought 류 추론 구간을 토큰 단위로
|
||||
// 걸러 사용자가 볼 필요 없는 생각 텍스트는 화면에 흐르지 않는다. false 면
|
||||
// 내부 누적 후 sanitize 된 최종 답변만 한 번에 표시.
|
||||
const postLiveDeltas = loopDepth === 0 && getConfig().liveStreamTokens === true;
|
||||
const liveFilter = postLiveDeltas ? new LiveReasoningFilter() : null;
|
||||
const postLiveToken = (token: string) => {
|
||||
const visible = liveFilter ? liveFilter.push(token) : token;
|
||||
if (visible) this.webview?.postMessage({ type: 'streamChunk', value: visible });
|
||||
};
|
||||
|
||||
let lmStudioStats: ChatStreamStats | undefined;
|
||||
if (useLmStudioSdk) {
|
||||
@@ -970,7 +1016,7 @@ export class AgentExecutor {
|
||||
if (this.isStaleRun(runId)) return;
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
|
||||
if (postLiveDeltas) postLiveToken(token);
|
||||
}
|
||||
if (stopReason) finishStopReason = stopReason;
|
||||
if (stats) lmStudioStats = stats;
|
||||
@@ -1045,7 +1091,7 @@ export class AgentExecutor {
|
||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
|
||||
if (postLiveDeltas) postLiveToken(token);
|
||||
}
|
||||
const fr = engine === 'lmstudio'
|
||||
? json.choices?.[0]?.finish_reason
|
||||
@@ -1077,7 +1123,7 @@ export class AgentExecutor {
|
||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
|
||||
if (postLiveDeltas) postLiveToken(token);
|
||||
}
|
||||
const fr = engine === 'lmstudio'
|
||||
? json.choices?.[0]?.finish_reason
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { detectPython, execFileCapture, _resetPythonCache } from '../../lib/execUtil';
|
||||
import type { HandlerContext } from './types';
|
||||
|
||||
// [코어 수렴] 실행 래퍼·Python 탐지는 lib/execUtil 단일 구현을 사용한다.
|
||||
// 기존 소비자(runCode·테스트) 호환을 위해 재수출.
|
||||
export { detectPython, _resetPythonCache };
|
||||
|
||||
/**
|
||||
* <calculate> — 수학·논리·수치 계산을 Python 에 위임하는 액션 (ACTION 16).
|
||||
*
|
||||
@@ -24,35 +28,6 @@ const MAX_CALC_PER_TURN = 4; // 한 턴에 과도한 실행 방지
|
||||
const MAX_CODE_CHARS = 4_000;
|
||||
const MAX_OUTPUT_CHARS = 4_000; // 모델 컨텍스트 보호
|
||||
|
||||
/** 감지된 Python 실행 파일 캐시 (프로세스 생존 동안 유지, null = 미탐지/없음). */
|
||||
let _pythonCmd: string | null | undefined;
|
||||
|
||||
function execFileP(cmd: string, args: string[], timeout: number): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
|
||||
if (err) reject(Object.assign(err, { stdout: String(stdout || ''), stderr: String(stderr || '') }));
|
||||
else resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** python3 → python → py 순으로 실행 가능한 인터프리터 탐지 (OS 자동 대응, run_code 도 공유). */
|
||||
export async function detectPython(): Promise<string | null> {
|
||||
if (_pythonCmd !== undefined) return _pythonCmd;
|
||||
for (const cmd of ['python3', 'python', 'py']) {
|
||||
try {
|
||||
await execFileP(cmd, ['--version'], 3_000);
|
||||
_pythonCmd = cmd;
|
||||
return cmd;
|
||||
} catch { /* 다음 후보 */ }
|
||||
}
|
||||
_pythonCmd = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 테스트용 — 캐시 초기화. */
|
||||
export function _resetPythonCache(): void { _pythonCmd = undefined; }
|
||||
|
||||
export async function applyCalculateActions(ctx: HandlerContext): Promise<void> {
|
||||
const { aiMessage, report } = ctx;
|
||||
const calcRegex = /<calculate>([\s\S]*?)<\/calculate>/gi;
|
||||
@@ -83,32 +58,33 @@ export async function applyCalculateActions(ctx: HandlerContext): Promise<void>
|
||||
}
|
||||
|
||||
const label = code.replace(/\s+/g, ' ').slice(0, 60);
|
||||
try {
|
||||
const { stdout } = await execFileP(python, ['-I', '-c', code], CALC_TIMEOUT_MS);
|
||||
const out = stdout.trim();
|
||||
if (!out) {
|
||||
// print 누락 — 모델이 스스로 고치도록 힌트를 재주입한다.
|
||||
report.push(`⚠️ Calculated (no output): ${label}`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: '[Result of calculate — EMPTY OUTPUT]\n코드는 실행됐지만 출력이 없습니다. 결과는 반드시 print(...) 로 출력해야 합니다. print 를 추가해 <calculate> 를 다시 emit 하십시오.',
|
||||
});
|
||||
} else {
|
||||
const capped = out.length > MAX_OUTPUT_CHARS ? out.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : out;
|
||||
report.push(`🧮 Calculated: ${label}`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: `[Result of calculate]\n\`\`\`\n${capped}\n\`\`\`\n이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
const r = await execFileCapture(python, ['-I', '-c', code], { timeoutMs: CALC_TIMEOUT_MS, maxBuffer: 512 * 1024 });
|
||||
|
||||
if (r.timedOut || r.code !== 0) {
|
||||
// 에러도 재주입 — 모델이 코드를 고쳐 재시도하는 자가수정 루프.
|
||||
const tail = String(err?.stderr || err?.message || err).trim().split('\n').slice(-8).join('\n').slice(0, 1200);
|
||||
const timedOut = err?.killed || /ETIMEDOUT|timed? ?out/i.test(String(err?.message || ''));
|
||||
const tail = (r.stderr || r.stdout || `exit ${r.code}`).trim().split('\n').slice(-8).join('\n').slice(0, 1200);
|
||||
report.push(`❌ Calculate failed: ${label}`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: `[Result of calculate — ERROR]\n${timedOut ? `실행이 ${CALC_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다. 더 효율적인 방법으로 다시 시도하십시오.` : `\`\`\`\n${tail}\n\`\`\`\n에러를 참고해 코드를 고친 뒤 <calculate> 를 다시 emit 하십시오. (표준 라이브러리만 사용 가능)`}`,
|
||||
content: `[Result of calculate — ERROR]\n${r.timedOut ? `실행이 ${CALC_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다. 더 효율적인 방법으로 다시 시도하십시오.` : `\`\`\`\n${tail}\n\`\`\`\n에러를 참고해 코드를 고친 뒤 <calculate> 를 다시 emit 하십시오. (표준 라이브러리만 사용 가능)`}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const out = r.stdout.trim();
|
||||
if (!out) {
|
||||
// print 누락 — 모델이 스스로 고치도록 힌트를 재주입한다.
|
||||
report.push(`⚠️ Calculated (no output): ${label}`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: '[Result of calculate — EMPTY OUTPUT]\n코드는 실행됐지만 출력이 없습니다. 결과는 반드시 print(...) 로 출력해야 합니다. print 를 추가해 <calculate> 를 다시 emit 하십시오.',
|
||||
});
|
||||
} else {
|
||||
const capped = out.length > MAX_OUTPUT_CHARS ? out.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : out;
|
||||
report.push(`🧮 Calculated: ${label}`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: `[Result of calculate]\n\`\`\`\n${capped}\n\`\`\`\n이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { execFile } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { validatePath } from '../../security';
|
||||
import { detectPython } from './calculate';
|
||||
import { detectPython, execFileCapture } from '../../lib/execUtil';
|
||||
import type { HandlerContext } from './types';
|
||||
|
||||
/**
|
||||
@@ -22,19 +21,6 @@ const RUN_TIMEOUT_MS = 15_000;
|
||||
const MAX_RUNS_PER_TURN = 2;
|
||||
const MAX_OUTPUT_CHARS = 4_000;
|
||||
|
||||
function execFileP(cmd: string, args: string[], cwd: string): Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, args, { cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err: any, stdout, stderr) => {
|
||||
resolve({
|
||||
code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
||||
stdout: String(stdout || ''),
|
||||
stderr: String(stderr || ''),
|
||||
timedOut: !!err?.killed,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const cap = (s: string) => (s.length > MAX_OUTPUT_CHARS ? s.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : s);
|
||||
|
||||
export async function applyRunCodeActions(ctx: HandlerContext): Promise<void> {
|
||||
@@ -87,7 +73,7 @@ export async function applyRunCodeActions(ctx: HandlerContext): Promise<void> {
|
||||
}
|
||||
|
||||
const rel = path.relative(rootPath, absPath) || relPath;
|
||||
const res = await execFileP(cmd, args, rootPath);
|
||||
const res = await execFileCapture(cmd, args, { timeoutMs: RUN_TIMEOUT_MS, cwd: rootPath, maxBuffer: 1024 * 1024 });
|
||||
const outBlock = [
|
||||
res.stdout.trim() ? `--- stdout ---\n${cap(res.stdout.trim())}` : '(stdout 없음)',
|
||||
res.stderr.trim() ? `--- stderr ---\n${cap(res.stderr.trim())}` : '',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildToneGuardBlock } from '../../lib/contextBuilders/toneGuard';
|
||||
import {
|
||||
isProjectKnowledgeCreationRequest,
|
||||
buildAstraStanceContext,
|
||||
@@ -76,7 +77,7 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
|
||||
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — negative constraints 는 보호.
|
||||
const casualCtx = isCasualConversation
|
||||
? '\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.'
|
||||
? `\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.\n\n${buildToneGuardBlock()}`
|
||||
: '';
|
||||
// Knowledge Mix policy: tells the model how strongly to lean on Second Brain
|
||||
// evidence vs. its own general knowledge for this turn. Suppressed for casual
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { LiveReasoningFilter } from '../../lib/contextBuilders/liveReasoningFilter';
|
||||
import { logError, summarizeText } from '../../utils';
|
||||
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
|
||||
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
|
||||
@@ -35,9 +36,12 @@ export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
|
||||
}): Promise<{ text: string; stopReason?: string; aborted: boolean }> {
|
||||
let accumulated = '';
|
||||
let finishStopReason: string | undefined;
|
||||
// 라이브 표시 시 추론 구간(<think>/Harmony thought)은 토큰 단위로 차단 — agent.ts 본 스트림과 동일.
|
||||
const liveFilter = new LiveReasoningFilter();
|
||||
const post = (token: string) => {
|
||||
if (params.postLiveDeltas && token) {
|
||||
deps.getWebview()?.postMessage({ type: 'streamChunk', value: token });
|
||||
const visible = liveFilter.push(token);
|
||||
if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { findBrainFiles, getActiveBrainProfile, logError } from '../../utils';
|
||||
import { findBrainFiles, getActiveBrainProfile, logError, logInfo } from '../../utils';
|
||||
import { getConfig } from '../../config';
|
||||
import { AgentWorkflowManager } from '../../agents/AgentWorkflowManager';
|
||||
import { ErrorTranslator } from '../../core/errorHandler';
|
||||
import { StatusBarManager, AgentStatus } from '../../core/statusBar';
|
||||
import { stripMarkdownFormatting } from '../../core/responseRecovery';
|
||||
import { buildIntentBrief, formatIntentBrief, runReportQaLoop, IntentBrief } from '../../agents/reportQa';
|
||||
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
|
||||
|
||||
export interface WorkflowDeps {
|
||||
@@ -50,6 +51,22 @@ export async function executeMultiAgentWorkflow(
|
||||
logError('Failed to load brain context for agents', ctxErr);
|
||||
}
|
||||
|
||||
// ── [의도 브리핑] 왜 묻는지·진짜 궁금증·답의 용도를 먼저 분석해 모든 단계
|
||||
// (outline/section/polish)의 컨텍스트에 주입 — "문자적 답변" 방지의 1차 장치.
|
||||
// 실패해도 파이프라인은 계속 (실패는 reportQa 모듈이 로그로 드러낸다).
|
||||
let intentBrief: IntentBrief | null = null;
|
||||
let intentCtx = '';
|
||||
if (getConfig().reportQaEnabled) {
|
||||
webview.postMessage({ type: 'workflowStage', value: { step: '⓪ 의도 분석', message: '질문의 목적을 파악하는 중...', done: false } });
|
||||
const recentContext = deps.chatHistory
|
||||
.filter(m => !(m as any).internal && (m.role === 'user' || m.role === 'assistant'))
|
||||
.slice(-4)
|
||||
.map(m => `${m.role === 'user' ? '사용자' : '아스트라'}: ${String(m.content || '').replace(/\s+/g, ' ').slice(0, 120)}`)
|
||||
.join('\n');
|
||||
intentBrief = await buildIntentBrief({ prompt, recentContext, model: modelName, signal });
|
||||
if (intentBrief) intentCtx = `${formatIntentBrief(intentBrief)}\n\n`;
|
||||
}
|
||||
|
||||
const selectedAgentContext = options.agentSkillContext
|
||||
? `\nSelected Agent Reference:\n${options.agentSkillContext}`
|
||||
: '';
|
||||
@@ -64,7 +81,7 @@ export async function executeMultiAgentWorkflow(
|
||||
const rawFinalReport = await AgentWorkflowManager.runStrictWorkflow(
|
||||
prompt,
|
||||
modelName,
|
||||
`${brainContext}${selectedAgentContext}${designerContext}`,
|
||||
`${intentCtx}${brainContext}${selectedAgentContext}${designerContext}`,
|
||||
signal,
|
||||
(step, msg) => {
|
||||
deps.getWebview()?.postMessage({
|
||||
@@ -77,12 +94,32 @@ export async function executeMultiAgentWorkflow(
|
||||
const wv2 = deps.getWebview();
|
||||
if (signal.aborted || !wv2) return;
|
||||
|
||||
// ── [Report QA Loop] 조사·보고서형 요청은 블로그_v3 방식 품질 루프를 통과 —
|
||||
// QA 규칙 레지스트리+실측치 주입으로 채점, 임계 미달이면 피드백 재작성,
|
||||
// 점수가 떨어지면 재작성본 폐기(회귀 게이트). 일반 복합 질문(코드 분석 등)은
|
||||
// 보고서 기준이 안 맞으므로 조사성 키워드가 있을 때만 발동.
|
||||
let qaCheckedReport = rawFinalReport;
|
||||
const isResearchLike = /(조사|리서치|보고서|레포트|알아봐|심층|종합\s*분석|research|report)/i.test(prompt);
|
||||
if (getConfig().reportQaEnabled && isResearchLike && !signal.aborted) {
|
||||
wv2.postMessage({ type: 'workflowStage', value: { step: '④ 품질 검증', message: '보고서를 채점하는 중...', done: false } });
|
||||
const qa = await runReportQaLoop({
|
||||
draft: rawFinalReport,
|
||||
prompt,
|
||||
intentBrief,
|
||||
model: modelName,
|
||||
signal,
|
||||
onProgress: (msg) => deps.getWebview()?.postMessage({ type: 'workflowStage', value: { step: '④ 품질 검증', message: msg, done: false } }),
|
||||
});
|
||||
qaCheckedReport = qa.finalDraft;
|
||||
logInfo('[Workflow] Report QA 완료.', { score: qa.score, revisions: qa.revisions, trajectory: qa.trajectory.join('→') });
|
||||
}
|
||||
|
||||
// [Plain Text Output] Synthesizer가 잘 따라줬어도 작은 모델은 `##` `**` 를 흘리는 경우가 있어
|
||||
// 최종 후처리로 한 번 더 마커를 벗긴다. 채팅 history 에도 정제된 결과만 남겨 다음 턴 컨텍스트에서
|
||||
// 마커가 재학습되는 일을 막는다.
|
||||
const finalReport = getConfig().outputFormat === 'plain'
|
||||
? stripMarkdownFormatting(rawFinalReport)
|
||||
: rawFinalReport;
|
||||
? stripMarkdownFormatting(qaCheckedReport)
|
||||
: qaCheckedReport;
|
||||
|
||||
wv2.postMessage({ type: 'streamChunk', value: finalReport });
|
||||
wv2.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
|
||||
|
||||
@@ -104,7 +104,6 @@ const confidenceEscalationHook: PostAnswerHook = {
|
||||
runAsync: false,
|
||||
run(ctx: PostAnswerHookContext): void {
|
||||
const cfg = getConfig();
|
||||
if (cfg.confidenceEngineEnabled === false) return;
|
||||
if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return;
|
||||
|
||||
// 검색이 안 돈 turn (casual 등) 은 신호 null → 보수적 기본값 (근거 0건).
|
||||
@@ -118,21 +117,21 @@ const confidenceEscalationHook: PostAnswerHook = {
|
||||
);
|
||||
const confidence = computeConfidence(retrievalSignals, answerSignals);
|
||||
|
||||
// 업무 산출물 turn 에만 footer 표시 — 잡담까지 점수 붙이면 노이즈.
|
||||
// 단, 확신도 '매우 낮음' 은 업무 여부와 무관하게 표시 (T4).
|
||||
const isTask = coverage.ran || coverage.taskId !== undefined;
|
||||
if (!isTask && confidence.band !== 'very-low') return;
|
||||
const decision = decideEscalation({
|
||||
confidence, coverage, conflictCount: retrievalSignals.conflictCount,
|
||||
});
|
||||
const escalated = decision.escalate;
|
||||
|
||||
let footer = formatConfidenceFooter(confidence);
|
||||
let escalated = false;
|
||||
if (cfg.escalationEnabled !== false) {
|
||||
const decision = decideEscalation({
|
||||
confidence, coverage, conflictCount: retrievalSignals.conflictCount,
|
||||
});
|
||||
escalated = decision.escalate;
|
||||
footer += formatEscalationFooter(decision);
|
||||
// [v2.2.300] 확신도·검토요청 footer 는 기본 *비표시* — 잡담 인사에까지
|
||||
// "확신도 40/100 · 사람 확인 필요" 가 붙는 노이즈라는 사용자 피드백.
|
||||
// 점수 계산과 아래 Reflection 기록은 그대로 유지 (성장 추이·Failure Pattern 의
|
||||
// 원천은 화면 표시가 아니라 기록이다). 다시 보고 싶으면 설정에서 켜기 (opt-in).
|
||||
if (cfg.confidenceEngineEnabled === true && (isTask || confidence.band === 'very-low')) {
|
||||
let footer = formatConfidenceFooter(confidence);
|
||||
if (cfg.escalationEnabled === true) footer += formatEscalationFooter(decision);
|
||||
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
||||
}
|
||||
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
||||
|
||||
// ── Reflection 기록 (Track 2-4 / 3-6) — 업무 turn 의 결정론적 회고를
|
||||
// <brain>/.astra/growth/reflections.jsonl 에 적재. 성장 추이·Failure Pattern 의 원천.
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* 위치: 답변 streaming 완료 후, `usedScope` 메시지 전송 직전. 비동기 — 답변
|
||||
* 표시를 *블록 하지 않음*. 결과는 webview 에 별도 메시지로 push.
|
||||
*/
|
||||
import { coreChat } from '../core/services';
|
||||
|
||||
|
||||
export interface SelfCheckOptions {
|
||||
ollamaUrl: string;
|
||||
@@ -141,53 +143,24 @@ export async function postHocSelfCheck(
|
||||
const sourcesCap = (sources || []).slice(0, options.maxSources);
|
||||
const { system, user } = buildPrompt(userPrompt, answer, sourcesCap, options.excerptLength);
|
||||
|
||||
const isOllama = options.ollamaUrl.includes(':11434') || options.ollamaUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${options.ollamaUrl}/api/chat` : `${options.ollamaUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — coreChat 으로 통일 (온도 0, 200토큰).
|
||||
// (이 모듈은 원래 import 0개의 자립 모듈이었으나, 엔진 폴백·로깅 일원화가 더 큰 가치)
|
||||
let raw = '';
|
||||
try {
|
||||
const body = isOllama
|
||||
? {
|
||||
model: options.model, stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
options: { temperature: 0.0, num_predict: 200 },
|
||||
}
|
||||
: {
|
||||
model: options.model, stream: false, temperature: 0.0, max_tokens: 200,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
};
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
const result = await coreChat({
|
||||
system, user, model: options.model,
|
||||
timeoutMs: options.timeoutMs,
|
||||
temperature: 0.0,
|
||||
maxTokens: 200,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
raw = String(
|
||||
data?.message?.content ??
|
||||
data?.choices?.[0]?.message?.content ??
|
||||
data?.choices?.[0]?.text ??
|
||||
data?.response ??
|
||||
'',
|
||||
);
|
||||
raw = String(result.content || '');
|
||||
} catch (e: any) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
...FAILURE_RESULT,
|
||||
note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`,
|
||||
durationMs: Date.now() - start,
|
||||
rawResponse: '',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const parsed = parseResult(raw);
|
||||
|
||||
+14
-71
@@ -1,82 +1,37 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../config';
|
||||
import { coreChat } from '../core/services';
|
||||
import { AgentExecuteOptions } from '../lib/engine';
|
||||
|
||||
export abstract class BaseAgent {
|
||||
constructor(protected readonly modelName: string) {}
|
||||
|
||||
protected async callLLM(persona: string, prompt: string, signal?: AbortSignal): Promise<string> {
|
||||
const { ollamaUrl } = getConfig();
|
||||
if (!ollamaUrl) {
|
||||
throw new Error('Ollama URL이 설정되지 않았습니다. 설정을 확인해주세요.');
|
||||
}
|
||||
|
||||
if (typeof fetch === 'undefined') {
|
||||
throw new Error('이 환경에서는 fetch 함수를 사용할 수 없습니다. Node.js 버전을 확인하거나 polyfill이 필요합니다.');
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: persona },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
|
||||
// 엔진 자동 감지 (Ollama vs OpenAI/LM Studio)
|
||||
const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`;
|
||||
|
||||
// 컨텍스트 초과 방지를 위해 출력 토큰 상한을 항상 명시한다 (서브에이전트 중간 산출물용).
|
||||
// [코어 수렴] 자체 fetch·엔진 휴리스틱을 제거하고 core/services 의 coreChat 으로 통일.
|
||||
// 이 레이어는 "시도당 45초 + 3회 재시도(지수 백오프) + Abort 즉시 중단" 정책만 담당하고,
|
||||
// 엔진 선택·폴백·빈응답 처리·로깅은 코어가 책임진다.
|
||||
const { contextLength, maxOutputTokens } = getConfig();
|
||||
const numCtx = Math.max(2048, contextLength);
|
||||
const outCap = Math.max(256, maxOutputTokens);
|
||||
|
||||
let lastError: any;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 45000);
|
||||
const combinedSignal = signal ? anySignal([signal, controller.signal]) : controller.signal;
|
||||
|
||||
try {
|
||||
if (attempt > 1) await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(isOllama ? {
|
||||
const result = await coreChat({
|
||||
system: persona,
|
||||
user: prompt,
|
||||
model: this.modelName,
|
||||
messages,
|
||||
stream: false,
|
||||
options: { temperature: 0.3, num_ctx: numCtx, num_predict: outCap }
|
||||
} : {
|
||||
model: this.modelName,
|
||||
messages,
|
||||
stream: false,
|
||||
timeoutMs: 45000,
|
||||
signal,
|
||||
temperature: 0.3,
|
||||
max_tokens: outCap
|
||||
}),
|
||||
signal: combinedSignal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Agent API Error: ${response.statusText} (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
|
||||
// 강력한 응답 추출 (Multi-path parsing)
|
||||
let content = '';
|
||||
if (data.message?.content) content = data.message.content;
|
||||
else if (data.choices?.[0]?.message?.content) content = data.choices[0].message.content;
|
||||
else if (data.choices?.[0]?.text) content = data.choices[0].text;
|
||||
else if (data.response) content = data.response;
|
||||
else if (typeof data === 'string') content = data;
|
||||
|
||||
return content || '';
|
||||
maxTokens: outCap,
|
||||
numCtx,
|
||||
});
|
||||
return result.content || '';
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
lastError = error;
|
||||
if (error.name === 'AbortError') break;
|
||||
if (error?.name === 'AbortError') break;
|
||||
if (attempt === 3) break;
|
||||
}
|
||||
}
|
||||
@@ -86,18 +41,6 @@ export abstract class BaseAgent {
|
||||
abstract execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string>;
|
||||
}
|
||||
|
||||
// Helper to combine signals (since AbortSignal.any is not always available in older Node)
|
||||
function anySignal(signals: AbortSignal[]): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
for (const signal of signals) {
|
||||
if (signal.aborted) {
|
||||
controller.abort();
|
||||
return signal;
|
||||
}
|
||||
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
}
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section outline shape produced by ChunkedWriter in the 'outline' role.
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { coreChat } from '../core/services';
|
||||
import { getConfig } from '../config';
|
||||
import { logInfo, logWarn } from '../utils';
|
||||
|
||||
/**
|
||||
* 조사·보고서 답변 품질 루프 — 블로그_v3 파이프라인의 검증 설계를 개념 이식.
|
||||
*
|
||||
* 이식한 개념 (원본: 블로그_v3 index.tsx — raw 복사가 아니라 보고서 도메인으로 적응):
|
||||
* 1. QA 규칙 레지스트리 단일 권위 — 채점 항목은 전부 REPORT_QA_RULES 배열에서
|
||||
* 생성된다. 규칙 추가/수정은 여기 한 곳만 고치면 된다.
|
||||
* 2. 결정적 실측치 주입 — 분량·섹션 수·출처 인용 수를 코드로 측정해 QA 프롬프트에
|
||||
* 주입한다. 모델의 눈대중 추정(들쭉날쭉)이 아니라 실측치로 판단을 안정화.
|
||||
* 3. 채점→피드백 재주입→재작성 루프 + 회귀 게이트 — 점수 미달이면 피드백을 넣어
|
||||
* 재작성하고 다시 채점하되, 점수가 *떨어지면* 그 재작성본은 버리고 최고점
|
||||
* 초안(best draft)을 유지한다 (85→79 회귀 사고의 재발 방지 장치).
|
||||
*
|
||||
* 소형 로컬 모델 전제: 라운드 수 기본 2회로 제한 (호출당 수십 초 — latency 보호),
|
||||
* 채점 실패(JSON 파싱 불가·타임아웃)는 루프 중단 + 현재 최고 초안 반환 (조용한
|
||||
* 폴백 금지 — 로그에 남긴다).
|
||||
*/
|
||||
|
||||
// ── QA 규칙 레지스트리 (단일 권위) ─────────────────────────────────────────
|
||||
// num: 항목 번호 — 피드백이 "N번"으로 참조하므로 기존 번호는 바꾸지 말고 새 규칙은 뒤에 추가.
|
||||
// critical: true 면 '중대한 결함'(임계 미달 사유) 목록에 자동 포함.
|
||||
export type ReportQaRule = { num: number; text: string; critical?: boolean };
|
||||
|
||||
export const REPORT_QA_RULES: ReportQaRule[] = [
|
||||
{ num: 1, text: '결론 선행 — 첫 문단이 질문에 대한 직접적인 답(핵심 결론 요약)으로 시작하는가. 배경 설명부터 길게 시작하면 감점', critical: true },
|
||||
{ num: 2, text: '의도 부합 — 함께 제공되는 [질문자 의도 브리핑]의 목적·진짜 궁금증에 본문이 실제로 답하는가. 표면 질문에만 답하고 목적을 놓쳤으면 크게 감점', critical: true },
|
||||
{ num: 3, text: '근거 명시 — 주장·사실마다 근거(제공된 자료의 문서명·출처, 또는 "일반 지식 기반" 표시)가 붙어 있는가. 출처 없는 단정이 반복되면 감점', critical: true },
|
||||
{ num: 4, text: '임의 수치 금지 — 제공된 자료에 없는 깔끔한 구체 수치(통계·비율·금액·날짜)를 지어내지 않았는가. 하나라도 보이면 크게 감점하고 feedback 에 해당 수치를 인용하라', critical: true },
|
||||
{ num: 5, text: '사실·추정 구분 — 확실한 사실과 추정·의견이 구분 표기되는가 ("~로 확인됨" vs "~로 추정됨"). 뒤섞여 있으면 감점' },
|
||||
{ num: 6, text: '구조 — 소제목으로 논리가 전개되고 (배경→핵심 발견→분석→시사점 류), 섹션 간 내용 중복이 없는가' },
|
||||
{ num: 7, text: '완결성 — 질문이 여러 하위 질문을 포함하면 전부 다뤘는가. 빠뜨린 하위 질문이 있으면 감점하고 feedback 에 명시하라', critical: true },
|
||||
{ num: 8, text: '한계 명시 — 이번 조사에서 확인하지 못한 것·추가 확인이 필요한 것이 마지막에 정직하게 표기되는가. 전지적 톤으로 끝나면 감점' },
|
||||
{ num: 9, text: '다음 행동 — 질문자가 이 답을 받고 취할 수 있는 다음 단계·시사점이 구체적으로 제시되는가' },
|
||||
{ num: 10, text: '군더더기 금지 — "물론입니다", "~에 대해 알아보겠습니다" 류 서두·맺음 인사, 동어반복 문단이 없는가' },
|
||||
{ num: 11, text: '나열 남용 금지 — 본문 대부분이 얕은 불릿 나열이면 감점. 핵심 논점은 문장·문단으로 설명되고 나열은 보조로만 쓰여야 한다' },
|
||||
{ num: 12, text: '분량 적정 — 함께 제공되는 실측치(본문 글자 수)를 그대로 사용하여(직접 추정 금지), 질문의 무게 대비 지나치게 짧아 부실하거나 반복으로 부풀려져 있지 않은가' },
|
||||
];
|
||||
|
||||
export const buildReportQaItems = (): string =>
|
||||
REPORT_QA_RULES.map(r => `(${r.num}) ${r.text}`).join('\n');
|
||||
export const reportQaCriticalNums = (): string =>
|
||||
REPORT_QA_RULES.filter(r => r.critical).map(r => r.num).join('·');
|
||||
|
||||
// ── 결정적 실측치 (모델 눈대중 대신 코드 측정) ──────────────────────────────
|
||||
export interface ReportMetrics {
|
||||
/** 마크다운 문법 제외 본문 글자 수 (공백 포함). */
|
||||
contentChars: number;
|
||||
/** 소제목(##/### 또는 굵은 단독행) 개수. */
|
||||
sectionCount: number;
|
||||
/** 출처 인용 흔적 수 — [문서명]·「」·(출처: …)·"일반 지식" 표기. */
|
||||
citationCount: number;
|
||||
/** 불릿/번호 행이 전체 비어있지 않은 행에서 차지하는 비율 (0~100). */
|
||||
listLineRatio: number;
|
||||
}
|
||||
|
||||
export function measureReport(text: string): ReportMetrics {
|
||||
const t = text || '';
|
||||
const contentChars = t
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/[#*_`>|-]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim().length;
|
||||
const lines = t.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
const sectionCount = lines.filter(l => /^#{2,4}\s+\S/.test(l) || /^\*\*[^*]{2,40}\*\*$/.test(l)).length;
|
||||
const citationCount =
|
||||
(t.match(/\(출처[::][^)]*\)/g) || []).length +
|
||||
(t.match(/「[^」]{2,60}」/g) || []).length +
|
||||
(t.match(/\[[^\]\n]{2,60}\](?!\()/g) || []).length +
|
||||
(t.match(/일반 지식/g) || []).length;
|
||||
const listLines = lines.filter(l => /^([-*•]|\d+[.)])\s/.test(l)).length;
|
||||
const listLineRatio = lines.length ? Math.round((listLines / lines.length) * 100) : 0;
|
||||
return { contentChars, sectionCount, citationCount, listLineRatio };
|
||||
}
|
||||
|
||||
// ── 관대한 JSON 파싱 (코드펜스·앞뒤 잡담 허용) — 블로그_v3 parseLooseJson 개념 ──
|
||||
export function parseLooseJson(raw: string): any {
|
||||
if (!raw) return null;
|
||||
const t = raw.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||||
try { return JSON.parse(t); } catch { /* fall through */ }
|
||||
const start = t.indexOf('{');
|
||||
const end = t.lastIndexOf('}');
|
||||
if (start !== -1 && end > start) {
|
||||
try { return JSON.parse(t.slice(start, end + 1)); } catch { /* fall through */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 질문자 의도 브리핑 (파이프라인 선두 1회 호출) ───────────────────────────
|
||||
export interface IntentBrief {
|
||||
/** 질문자가 왜 지금 이걸 묻는지 — 추정 목적. */
|
||||
purpose: string;
|
||||
/** 표면 질문 뒤의 진짜 궁금증들. */
|
||||
realQuestions: string[];
|
||||
/** 답을 받아 무엇을 하려는지 — 기대 산출물 형태. */
|
||||
expectedUse: string;
|
||||
}
|
||||
|
||||
export function formatIntentBrief(brief: IntentBrief): string {
|
||||
return [
|
||||
'[질문자 의도 브리핑]',
|
||||
`- 추정 목적: ${brief.purpose}`,
|
||||
`- 진짜 궁금증: ${brief.realQuestions.join(' / ')}`,
|
||||
`- 답의 용도: ${brief.expectedUse}`,
|
||||
'위 브리핑에 실제로 답하는 것이 이번 답변의 성공 기준이다. 표면 질문만 문자적으로 답하지 마라.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 질문 + 최근 대화 요지로 의도 브리핑을 1회 생성. 실패 시 null (호출자가
|
||||
* 브리핑 없이 진행 — 실패는 로그로 드러낸다, 조용한 폴백 금지).
|
||||
*/
|
||||
export async function buildIntentBrief(params: {
|
||||
prompt: string;
|
||||
recentContext?: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<IntentBrief | null> {
|
||||
try {
|
||||
const res = await coreChat({
|
||||
system: '너는 질문 분석가다. 사용자의 요청을 읽고 왜 묻는지(목적), 표면 질문 뒤에 정말 궁금한 것, 답을 받아 무엇을 할지 분석해 JSON 으로만 응답한다. 형식: {"purpose":"...","realQuestions":["...","..."],"expectedUse":"..."} — 각 항목은 한국어 한 문장, realQuestions 는 1~3개.',
|
||||
user: `${params.recentContext ? `[최근 대화 요지]\n${params.recentContext}\n\n` : ''}[사용자 요청]\n${params.prompt}`,
|
||||
model: params.model,
|
||||
temperature: 0.2,
|
||||
maxTokens: 300,
|
||||
timeoutMs: 30_000,
|
||||
signal: params.signal,
|
||||
});
|
||||
const parsed = parseLooseJson(res.content);
|
||||
if (parsed && typeof parsed.purpose === 'string' && Array.isArray(parsed.realQuestions)) {
|
||||
return {
|
||||
purpose: parsed.purpose,
|
||||
realQuestions: parsed.realQuestions.map(String).filter(Boolean).slice(0, 3),
|
||||
expectedUse: String(parsed.expectedUse || '명시되지 않음'),
|
||||
};
|
||||
}
|
||||
logWarn('[ReportQA] 의도 브리핑 JSON 파싱 실패 — 브리핑 없이 진행.', { raw: res.content.slice(0, 120) });
|
||||
return null;
|
||||
} catch (e: any) {
|
||||
logWarn('[ReportQA] 의도 브리핑 호출 실패 — 브리핑 없이 진행.', { error: e?.message || String(e) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── QA 채점 + 재작성 루프 + 회귀 게이트 ────────────────────────────────────
|
||||
export interface QaLoopResult {
|
||||
finalDraft: string;
|
||||
/** 최종 채택 초안의 점수. 채점이 한 번도 성공 못 하면 null. */
|
||||
score: number | null;
|
||||
/** 실행된 재작성 횟수. */
|
||||
revisions: number;
|
||||
/** 라운드별 점수 궤적 (로그·텔레메트리용). */
|
||||
trajectory: number[];
|
||||
}
|
||||
|
||||
async function scoreOnce(params: {
|
||||
draft: string;
|
||||
prompt: string;
|
||||
intentText: string;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ score: number; feedback: string } | null> {
|
||||
const m = measureReport(params.draft);
|
||||
const metricsGuide =
|
||||
`[실측치 — 그대로 사용, 직접 추정 금지] 본문 글자 수 ${m.contentChars}자, 소제목 ${m.sectionCount}개, ` +
|
||||
`출처 인용 흔적 ${m.citationCount}건, 나열(불릿) 행 비율 ${m.listLineRatio}%.`;
|
||||
try {
|
||||
const res = await coreChat({
|
||||
system: `너는 조사 보고서 품질 검수관이다. 아래 기준으로 엄격히 채점해 JSON 으로만 응답한다: {"score": 0~100, "feedback": "부족한 항목 번호와 해당 구절 인용"}.\n[채점 기준]\n${buildReportQaItems()}\n[점수 규칙] 모든 항목 충족 시 85점 이상. 사소한 미흡 1개면 80~84점. 중대 결함(${reportQaCriticalNums()}번 위반) 또는 미흡 2개 이상이면 80점 미만. 점수와 무관하게 feedback 에는 무엇이 부족한지 구체적 구절을 인용해 반드시 명시하라.`,
|
||||
user: `${params.intentText}\n\n[원 질문]\n${params.prompt}\n\n${metricsGuide}\n\n[검수 대상 보고서]\n${params.draft}`,
|
||||
model: params.model,
|
||||
temperature: 0.0,
|
||||
maxTokens: 500,
|
||||
timeoutMs: 45_000,
|
||||
signal: params.signal,
|
||||
});
|
||||
const parsed = parseLooseJson(res.content);
|
||||
if (parsed && typeof parsed.score === 'number') {
|
||||
return { score: Math.max(0, Math.min(100, parsed.score)), feedback: String(parsed.feedback || '') };
|
||||
}
|
||||
logWarn('[ReportQA] 채점 JSON 파싱 실패.', { raw: res.content.slice(0, 120) });
|
||||
return null;
|
||||
} catch (e: any) {
|
||||
logWarn('[ReportQA] 채점 호출 실패.', { error: e?.message || String(e) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 초안을 채점하고, 임계 미달이면 피드백을 재주입해 재작성 → 재채점.
|
||||
* 재작성본 점수가 이전 최고점보다 낮으면 폐기(회귀 게이트).
|
||||
*/
|
||||
export async function runReportQaLoop(params: {
|
||||
draft: string;
|
||||
prompt: string;
|
||||
intentBrief: IntentBrief | null;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<QaLoopResult> {
|
||||
const cfg = getConfig();
|
||||
const threshold = cfg.reportQaThreshold;
|
||||
const maxRevisions = cfg.reportQaMaxRevisions;
|
||||
const intentText = params.intentBrief ? formatIntentBrief(params.intentBrief) : '[질문자 의도 브리핑] (생성 실패 — 원 질문 기준으로 판단하라)';
|
||||
|
||||
let bestDraft = params.draft;
|
||||
let bestScore: number | null = null;
|
||||
const trajectory: number[] = [];
|
||||
let revisions = 0;
|
||||
|
||||
let current = await scoreOnce({ draft: bestDraft, prompt: params.prompt, intentText, model: params.model, signal: params.signal });
|
||||
if (!current) return { finalDraft: bestDraft, score: null, revisions: 0, trajectory };
|
||||
bestScore = current.score;
|
||||
trajectory.push(current.score);
|
||||
logInfo('[ReportQA] 초안 채점.', { score: current.score, threshold });
|
||||
|
||||
while (bestScore < threshold && revisions < maxRevisions) {
|
||||
if (params.signal?.aborted) break;
|
||||
revisions++;
|
||||
params.onProgress?.(`품질 ${bestScore}점 — 보완 재작성 ${revisions}/${maxRevisions}`);
|
||||
let revised: string;
|
||||
try {
|
||||
const res = await coreChat({
|
||||
system: '너는 조사 보고서 작성자다. 아래 검수 피드백을 반영해 보고서 *전문*을 다시 작성하라. 지적받지 않은 부분의 사실·구조는 유지하고, 지적된 부분만 고친다. 보고서 본문만 출력하고 다른 말은 하지 마라.',
|
||||
user: `${intentText}\n\n[원 질문]\n${params.prompt}\n\n[검수 피드백 (점수 ${bestScore}/${threshold})]\n${current.feedback}\n\n[현재 보고서]\n${bestDraft}`,
|
||||
model: params.model,
|
||||
temperature: 0.4,
|
||||
maxTokens: getConfig().maxOutputTokens,
|
||||
timeoutMs: 180_000,
|
||||
signal: params.signal,
|
||||
});
|
||||
revised = (res.content || '').trim();
|
||||
} catch (e: any) {
|
||||
logWarn('[ReportQA] 재작성 호출 실패 — 현재 최고 초안 유지.', { error: e?.message || String(e) });
|
||||
break;
|
||||
}
|
||||
if (!revised || revised.length < bestDraft.length * 0.4) {
|
||||
// 재작성이 비었거나 심하게 잘림 — 회귀로 간주하고 폐기.
|
||||
logWarn('[ReportQA] 재작성본이 비었거나 과도하게 짧음 — 폐기.', { revisedChars: revised.length, bestChars: bestDraft.length });
|
||||
continue;
|
||||
}
|
||||
const rescored = await scoreOnce({ draft: revised, prompt: params.prompt, intentText, model: params.model, signal: params.signal });
|
||||
if (!rescored) break;
|
||||
trajectory.push(rescored.score);
|
||||
if (rescored.score >= bestScore) {
|
||||
bestDraft = revised;
|
||||
bestScore = rescored.score;
|
||||
current = rescored;
|
||||
logInfo('[ReportQA] 재작성 채택.', { round: revisions, score: rescored.score });
|
||||
} else {
|
||||
// 회귀 게이트 — 편집이 점수를 떨어뜨리면 그 편집본은 버린다 (블로그_v3 의 85→79 사고 방지).
|
||||
logInfo('[ReportQA] 재작성본 점수 하락 — 회귀 게이트로 폐기.', { round: revisions, was: bestScore, got: rescored.score });
|
||||
}
|
||||
}
|
||||
|
||||
return { finalDraft: bestDraft, score: bestScore, revisions, trajectory };
|
||||
}
|
||||
+27
-4
@@ -143,6 +143,16 @@ export interface IAgentConfig {
|
||||
* 깨끗한 답변에는 안 돈다 (latency 보호). 기본 true. (Phase 1 / Track 2-3)
|
||||
*/
|
||||
criticLoopEnabled: boolean;
|
||||
/**
|
||||
* Report QA Loop — 조사·보고서형 워크플로우 답변에 블로그_v3 방식 품질 루프 적용:
|
||||
* 의도 브리핑(왜 묻는지 분석) → 초안 → QA 채점(규칙 레지스트리+실측치 주입) →
|
||||
* 피드백 재작성 → 회귀 게이트(점수 하락 시 재작성본 폐기). 기본 true. (v2.2.301)
|
||||
*/
|
||||
reportQaEnabled: boolean;
|
||||
/** Report QA 통과 점수 임계 (이 미만이면 재작성). 기본 80. */
|
||||
reportQaThreshold: number;
|
||||
/** Report QA 최대 재작성 횟수 (로컬 모델 latency 보호). 기본 2. */
|
||||
reportQaMaxRevisions: number;
|
||||
/**
|
||||
* Reflection — 업무 turn 의 결정론적 회고(확신도·누락 요소·에스컬레이션)를
|
||||
* <brain>/.astra/growth/reflections.jsonl 에 기록. 성장 추이·반복 실수 집계의
|
||||
@@ -394,8 +404,8 @@ export interface IAgentConfig {
|
||||
// ─── Stream 표시 ───
|
||||
/**
|
||||
* 모델 토큰을 받는 즉시 채팅 버블에 흘려보낼지 여부.
|
||||
* - false(기본): 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커 누설 원천 차단.
|
||||
* - true: legacy 라이브 스트리밍. 모델 출력에 control token 이 섞여 나오면 잠깐 화면에 보일 수 있음.
|
||||
* - true(기본): 실시간 표시. 추론 구간(<think>/Harmony thought)은 LiveReasoningFilter 가 토큰 단위로 차단.
|
||||
* - false: 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시.
|
||||
*/
|
||||
liveStreamTokens: boolean;
|
||||
/**
|
||||
@@ -460,6 +470,15 @@ function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: n
|
||||
}
|
||||
|
||||
// ─── VS Code 설정에서 읽어오는 값 (통합 구현) ───
|
||||
//
|
||||
// [설정 접근 규칙 — 2026-07-11 아키텍처 감사에서 명문화]
|
||||
// IAgentConfig 에 이미 있는 값(엔진 URL·모델·컨텍스트 한계·두뇌 프로필 등
|
||||
// "코어 계약"에 속하는 값)은 반드시 getConfig() 를 통해 읽는다 — 기본값·클램프·
|
||||
// 레거시 마이그레이션이 여기 한 곳에 있기 때문. 반대로 특정 기능 전용 설정
|
||||
// (예: g1nation.stocks.*, dailyBriefing.time)은 그 기능 파일에서
|
||||
// vscode.workspace.getConfiguration('g1nation') 직접 접근이 허용된다 —
|
||||
// 기능 하나 추가할 때마다 이 인터페이스를 비대하게 만들지 않기 위한 의도적 분리.
|
||||
// 판단 기준: "둘 이상의 모듈이 읽는 값인가?" → 그렇다면 IAgentConfig 로 승격할 것.
|
||||
export function getConfig(): IAgentConfig {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
|
||||
@@ -535,9 +554,13 @@ export function getConfig(): IAgentConfig {
|
||||
requirementGraphEnabled: cfg.get<boolean>('requirementGraphEnabled', true),
|
||||
requirementCoverageEnabled: cfg.get<boolean>('requirementCoverageEnabled', true),
|
||||
epistemicGuardEnabled: cfg.get<boolean>('epistemicGuardEnabled', true),
|
||||
confidenceEngineEnabled: cfg.get<boolean>('confidenceEngineEnabled', true),
|
||||
escalationEnabled: cfg.get<boolean>('escalationEnabled', true),
|
||||
// v2.2.300: footer 기본 비표시 (사용자 피드백) — 계산·Reflection 은 항상 수행.
|
||||
confidenceEngineEnabled: cfg.get<boolean>('confidenceEngineEnabled', false),
|
||||
escalationEnabled: cfg.get<boolean>('escalationEnabled', false),
|
||||
criticLoopEnabled: cfg.get<boolean>('criticLoopEnabled', true),
|
||||
reportQaEnabled: cfg.get<boolean>('reportQaEnabled', true),
|
||||
reportQaThreshold: Math.max(50, Math.min(95, cfg.get<number>('reportQaThreshold', 80))),
|
||||
reportQaMaxRevisions: Math.max(0, Math.min(5, cfg.get<number>('reportQaMaxRevisions', 2))),
|
||||
reflectionEnabled: cfg.get<boolean>('reflectionEnabled', true),
|
||||
orgMemoryEnabled: cfg.get<boolean>('orgMemoryEnabled', true),
|
||||
actionabilityEnabled: cfg.get<boolean>('actionabilityEnabled', true),
|
||||
|
||||
+3
-6
@@ -2,11 +2,8 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getConfig } from '../config';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { execShell } from '../lib/execUtil';
|
||||
import { logInfo, logWarn, logError, getActiveBrainProfile } from '../utils';
|
||||
import { getBridgeBaseUrl } from '../features/datacollect/bridgeClient';
|
||||
|
||||
@@ -111,10 +108,10 @@ export class HealthCheckMonitor {
|
||||
try {
|
||||
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
|
||||
try {
|
||||
await execAsync('git push --dry-run', {
|
||||
await execShell('git push --dry-run', {
|
||||
cwd: brain.localBrainPath,
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||
timeout: 5000,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.stderr || e?.message || '');
|
||||
|
||||
+44
-2
@@ -26,6 +26,12 @@ export interface AIChatRequest {
|
||||
model?: string;
|
||||
/** Optional override (default = config.timeout). */
|
||||
timeoutMs?: number;
|
||||
/** 샘플링 온도 (기본 0.7). 판정·재순위 등 결정적 작업은 0.0~0.2 권장. */
|
||||
temperature?: number;
|
||||
/** 출력 토큰 상한 (ollama num_predict / lmstudio max_tokens). 미지정 시 서버 기본. */
|
||||
maxTokens?: number;
|
||||
/** [ollama 전용] 컨텍스트 창 크기(num_ctx). */
|
||||
numCtx?: number;
|
||||
/**
|
||||
* 외부 abort signal. fetch 가 받는 signal 과 OR 로 결합되어, 사용자가 회사 모드
|
||||
* 도중 Stop 을 누르면 진행 중인 generation 이 즉시 중단된다. 없으면 timeout 만
|
||||
@@ -37,7 +43,7 @@ export interface AIChatRequest {
|
||||
export interface AIChatResult {
|
||||
content: string;
|
||||
/** Engine that actually returned the content. */
|
||||
engine: 'lmstudio' | 'ollama';
|
||||
engine: 'lmstudio' | 'ollama' | 'claude-code';
|
||||
model: string;
|
||||
/** True iff content came back empty after all retries. Caller decides UX. */
|
||||
empty: boolean;
|
||||
@@ -73,6 +79,23 @@ export class AIService implements IAIService {
|
||||
const config = getConfig();
|
||||
const model = (req.model || config.defaultModel || '').trim() || 'gemma4:e2b';
|
||||
const timeoutMs = req.timeoutMs ?? config.timeout;
|
||||
|
||||
// [v2.2.305] Claude 구독 모델 ('claude:sonnet' 등) — 로컬 엔진 대신 Claude Code CLI 위임.
|
||||
// 워크플로우·Report QA·리랭크 등 coreChat 소비자 전부가 구독 모델로 동작 가능해진다.
|
||||
// (다른 클라우드 prefix 는 스트리밍 전용 경로만 지원 — 여기 오면 아래 로컬 시도가 실패로 드러남)
|
||||
if (model.startsWith('claude:')) {
|
||||
const { runClaudeCode } = await import('../features/providers/claudeCode');
|
||||
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [];
|
||||
if (req.system && req.system.trim()) messages.push({ role: 'system', content: req.system });
|
||||
messages.push({ role: 'user', content: req.user });
|
||||
const r = await runClaudeCode({
|
||||
messages,
|
||||
model: model.slice('claude:'.length),
|
||||
signal: req.signal,
|
||||
timeoutMs: Math.max(timeoutMs, 120_000),
|
||||
});
|
||||
return { content: r.text, engine: 'claude-code', model, empty: !r.text.trim() };
|
||||
}
|
||||
const primaryEngine = resolveEngine(config.ollamaUrl);
|
||||
const engines = primaryEngine === 'lmstudio'
|
||||
? ['lmstudio', 'ollama'] as const
|
||||
@@ -89,11 +112,21 @@ export class AIService implements IAIService {
|
||||
|
||||
for (const engine of engines) {
|
||||
const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat');
|
||||
const temperature = req.temperature ?? 0.7;
|
||||
const payload = {
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
...(engine === 'ollama' ? { options: { temperature: 0.7 } } : { temperature: 0.7 }),
|
||||
...(engine === 'ollama'
|
||||
? { options: {
|
||||
temperature,
|
||||
...(req.maxTokens != null ? { num_predict: req.maxTokens } : {}),
|
||||
...(req.numCtx != null ? { num_ctx: req.numCtx } : {}),
|
||||
} }
|
||||
: {
|
||||
temperature,
|
||||
...(req.maxTokens != null ? { max_tokens: req.maxTokens } : {}),
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -151,6 +184,15 @@ export class AIService implements IAIService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [코어 수렴] 인스턴스 없이 코어 LLM 경로를 쓰는 모듈 함수용 헬퍼.
|
||||
* AIService 는 무상태(설정을 매 호출 읽음)라 안전하다. 엔진 폴백·타임아웃·abort·
|
||||
* 로깅·빈응답 소프트실패가 모두 이 한 경로로 통일된다 — 자체 fetch 금지.
|
||||
*/
|
||||
export function coreChat(req: AIChatRequest): Promise<AIChatResult> {
|
||||
return new AIService().chat(req);
|
||||
}
|
||||
|
||||
/**
|
||||
* BrainService: 지식 베이스 파일 시스템 저장 및 관리 구현체
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as path from 'path';
|
||||
// 가 이미 로드된 후 등록되도록 entry point 에서. (v2.2.196~201 도메인별 파일 분리)
|
||||
import './features/teamops/handlers';
|
||||
import './features/system/handlers';
|
||||
import './features/claude/claudeLauncher'; // /claude — Claude Code CLI 위임 (v2.2.303)
|
||||
import './features/datacollect/handlers';
|
||||
import './features/email/handlers'; // /email — Gmail + Outlook (v2.2.269)
|
||||
// axios removed in favor of native fetch
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* 스케줄링은 stocksWatcher 와 동일한 단일 setTimeout 체인 패턴 (KST 고정).
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
|
||||
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
|
||||
@@ -22,25 +23,8 @@ let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
let _lastFiredYmd = ''; // 같은 날 중복 발송 방지 (타이머 드리프트 대비)
|
||||
|
||||
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
|
||||
const now = new Date();
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(now);
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
|
||||
// 'YYYY-MM-DD' → UTC midnight Date — getUTCDay 가 그 날짜의 요일 (0=일).
|
||||
const weekday = new Date(`${ymd}T00:00:00Z`).getUTCDay();
|
||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday };
|
||||
}
|
||||
|
||||
function briefingTime(): { hour: number; minute: number } {
|
||||
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30') || '09:30').trim();
|
||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!m) return { hour: 9, minute: 30 };
|
||||
return { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) };
|
||||
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30'), 9, 30);
|
||||
}
|
||||
|
||||
/** 다음 발송까지 ms — 평일 스킵 포함. */
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { registerSlashCommand, chunk, type Webview } from '../datacollect/slashRouter';
|
||||
|
||||
/**
|
||||
* /claude — 로컬에 설치된 Claude Code CLI 를 VS Code 통합 터미널에서 실행.
|
||||
*
|
||||
* 배경 (v2.2.303): 벤치마킹→개발 파이프라인의 마지막 구간. 로컬 소형 모델은
|
||||
* 분석·명세(핸드오프 문서)까지 담당하고, 실제 코드 생성은 클라우드 모델에
|
||||
* 위임한다. Anthropic 구독(Pro/Max) 인증은 API 키가 아니라 Claude Code 로그인
|
||||
* 방식이므로, Astra 가 API 를 직접 호출하는 대신 **사용자가 이미 로그인해 둔
|
||||
* `claude` CLI 를 실행**한다 — 추가 과금 없이 구독 한도 안에서 동작.
|
||||
*
|
||||
* 설계 (bridgeLauncher 의 터미널 패턴 재사용):
|
||||
* - 통합 터미널("Claude Code (Astra)")에서 실행 → 셸/OS 차이를 VS Code 가 흡수,
|
||||
* Claude Code 의 권한 승인·진행 상황을 사용자가 눈으로 보며 제어.
|
||||
* - cwd = 첫 워크스페이스 폴더 (Claude Code 가 그 프로젝트 컨텍스트로 동작).
|
||||
* - 지시문은 첫 인자로 전달 — 따옴표는 셸 호환을 위해 정규화.
|
||||
*/
|
||||
|
||||
const TERMINAL_NAME = 'Claude Code (Astra)';
|
||||
|
||||
/** 셸(zsh/bash/PowerShell) 공통으로 안전한 한 줄 인자로 정규화 (순수 — 테스트 대상). */
|
||||
export function buildClaudeCommand(instruction: string): string {
|
||||
const t = (instruction || '').trim();
|
||||
if (!t) return 'claude';
|
||||
// 큰따옴표로 감싸므로 내부의 "는 ', POSIX 확장 문자와 PowerShell 백틱은 공백으로 완화.
|
||||
// 지시문은 터미널에서 사용자가 이어서 수정할 수 있으니 완벽 이스케이프보다 안전 우선.
|
||||
const safe = t.replace(/"/g, "'").replace(/[`$\\]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return `claude "${safe}"`;
|
||||
}
|
||||
|
||||
async function runClaude(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
const instruction = (arg || '').trim();
|
||||
|
||||
const ws = vscode.workspace.workspaceFolders?.find(f => f.uri.scheme === 'file');
|
||||
const cwd = ws?.uri.fsPath;
|
||||
|
||||
// 기존 우리 터미널이 있으면 재사용하지 않고 정리 후 새로 연다 (죽은 셸 방지 — bridgeLauncher 동일).
|
||||
vscode.window.terminals.find(t => t.name === TERMINAL_NAME)?.dispose();
|
||||
const terminal = vscode.window.createTerminal({ name: TERMINAL_NAME, cwd });
|
||||
terminal.show(true);
|
||||
terminal.sendText(buildClaudeCommand(instruction));
|
||||
|
||||
chunk(view, [
|
||||
`🚀 **Claude Code 실행** — "${TERMINAL_NAME}" 터미널에서 진행됩니다.`,
|
||||
instruction ? `- 전달한 지시: ${instruction.slice(0, 200)}${instruction.length > 200 ? '…' : ''}` : '- 지시 없이 대화형으로 시작했습니다.',
|
||||
cwd ? `- 작업 폴더: \`${cwd}\`` : '- ⚠️ 열린 워크스페이스가 없어 홈 폴더에서 시작합니다.',
|
||||
'- 파일 수정·명령 실행 승인은 터미널에서 직접 하시면 됩니다 (구독 인증 그대로 사용).',
|
||||
'',
|
||||
'💡 `command not found: claude` 가 뜨면: 터미널에서 `npm install -g @anthropic-ai/claude-code` 설치 후 `claude` 를 실행해 로그인하세요.',
|
||||
'',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
registerSlashCommand({
|
||||
name: '/claude',
|
||||
description: 'Claude Code(클라우드·구독 인증)를 터미널로 실행 — 핸드오프 문서 기반 개발 위임. 예: /claude <파일경로> 읽고 명세대로 개발해줘',
|
||||
handler: runClaude,
|
||||
});
|
||||
@@ -161,6 +161,12 @@ export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;
|
||||
export interface DispatcherDeps {
|
||||
context: vscode.ExtensionContext;
|
||||
ai: IAIService;
|
||||
/**
|
||||
* [코어 수렴] 두뇌 컨텍스트 검색 thunk — 있으면 메인 RetrievalOrchestrator 의
|
||||
* 전체 검색 경로(임베딩 하이브리드·청크)를 사용한다. 없으면(텔레그램 등 경량
|
||||
* 환경) 기존 scopedBrainRetriever 로 폴백. 반환 포맷은 buildContextBlock 동일.
|
||||
*/
|
||||
retrieveBrainContext?: (query: string, scopeFolders: string[], limit: number) => Promise<string>;
|
||||
/** Default model to fall back to when an agent has no override. */
|
||||
defaultModel: string;
|
||||
/**
|
||||
@@ -667,10 +673,16 @@ async function _dispatchOne(
|
||||
// the whole brain so a missing mapping doesn't starve the
|
||||
// dispatcher.
|
||||
const scope = resolveScopeForAgent(agentId, brainRoot);
|
||||
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
|
||||
maxResults: brainFileLimit,
|
||||
});
|
||||
brainContext = buildContextBlock(retrieval);
|
||||
if (deps.retrieveBrainContext) {
|
||||
// 메인 오케스트레이터 경로 — 임베딩 하이브리드·청크·개선 사항을 그대로 수혜.
|
||||
brainContext = await deps.retrieveBrainContext(task, scope.folders, brainFileLimit);
|
||||
} else {
|
||||
// 경량 폴백 (텔레그램 등 오케스트레이터가 없는 소비자).
|
||||
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
|
||||
maxResults: brainFileLimit,
|
||||
});
|
||||
brainContext = buildContextBlock(retrieval);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { execShell } from '../../lib/execUtil';
|
||||
|
||||
/**
|
||||
* NotebookLM 로컬 백엔드(Datacollect Bridge) 원클릭 실행/종료.
|
||||
@@ -133,7 +130,7 @@ export async function stopNotebookLmBridge(): Promise<void> {
|
||||
await new Promise(r => setTimeout(r, 600)); // 터미널 dispose 가 프로세스를 정리할 시간
|
||||
if (await isBridgeAlive()) {
|
||||
try {
|
||||
await execAsync(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeout: 8000 });
|
||||
await execShell(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeoutMs: 8000 });
|
||||
} catch { /* 이미 죽었거나 권한 문제 — 아래 재확인으로 판정 */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,15 @@ import { promises as fsp } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { registerSlashCommand, chunk, type Webview } from './slashRouter';
|
||||
import { callLmSynthesis } from './llm';
|
||||
import { getConfig } from '../../config';
|
||||
import { logInfo } from '../../utils';
|
||||
import { runReportQaLoop } from '../../agents/reportQa';
|
||||
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
|
||||
import { collectSourceFiles } from './reviewFiles';
|
||||
import { buildReviewFilePrompt, buildReviewReducePrompt } from './prompts/reviewPrompt';
|
||||
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
|
||||
import { buildHandoffDoc } from './prompts/handoffDoc';
|
||||
import { buildLayoutSkeleton } from './prompts/layoutSkeleton';
|
||||
import {
|
||||
type YoutubeAnalysisMode,
|
||||
formatHms,
|
||||
@@ -49,28 +54,64 @@ import {
|
||||
|
||||
// ───────────────────────────── /benchmark ─────────────────────────────
|
||||
|
||||
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
/**
|
||||
* /benchmark 모드 → 실행할 합성 파트 목록.
|
||||
* - full(기본): 디자인 3파트 + 비즈니스 렌즈 (1~4)
|
||||
* - design: 기존 3파트만 (원본 복원 시방서)
|
||||
* - biz: 비즈니스 렌즈만 (빠름 — 포지셔닝·가격·운영)
|
||||
* - build: 풀코스 + 스텝별 빌드 프롬프트 (1~5)
|
||||
*/
|
||||
export type BenchmarkMode = 'full' | 'design' | 'biz' | 'build' | 'handoff';
|
||||
export const BENCHMARK_MODE_PARTS: Record<BenchmarkMode, ReadonlyArray<1 | 2 | 3 | 4 | 5>> = {
|
||||
full: [1, 2, 3, 4],
|
||||
design: [1, 2, 3],
|
||||
biz: [4],
|
||||
build: [1, 2, 3, 4, 5],
|
||||
handoff: [], // LLM 합성 없음 — 스캔 원시 데이터로 인수인계 문서를 결정론 생성
|
||||
};
|
||||
|
||||
/** /benchmark 인자 파싱 — 순수 함수 (테스트 대상). */
|
||||
export function parseBenchmarkArgs(arg: string): {
|
||||
url: string; depth?: number; pages?: number; mode: BenchmarkMode; userContent: string;
|
||||
} {
|
||||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||||
let url = '';
|
||||
let depthArg: number | undefined;
|
||||
let pagesArg: number | undefined;
|
||||
let depth: number | undefined;
|
||||
let pages: number | undefined;
|
||||
let mode: BenchmarkMode = 'full';
|
||||
const restParts: string[] = [];
|
||||
const BARE_MODES = new Set<BenchmarkMode>(['full', 'design', 'biz', 'build', 'handoff']);
|
||||
for (const t of tokens) {
|
||||
const m = /^(depth|pages)=(\d+)$/i.exec(t);
|
||||
const modeM = /^mode[:=](full|design|biz|build|handoff)$/i.exec(t);
|
||||
if (m) {
|
||||
if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
|
||||
else pagesArg = Number(m[2]);
|
||||
if (m[1].toLowerCase() === 'depth') depth = Number(m[2]);
|
||||
else pages = Number(m[2]);
|
||||
} else if (modeM) {
|
||||
mode = modeM[1].toLowerCase() as BenchmarkMode;
|
||||
} else if (BARE_MODES.has(t.toLowerCase() as BenchmarkMode)) {
|
||||
mode = t.toLowerCase() as BenchmarkMode;
|
||||
} else if (!url) {
|
||||
url = t;
|
||||
} else {
|
||||
restParts.push(t);
|
||||
}
|
||||
}
|
||||
return { url, depth, pages, mode, userContent: restParts.join(' ') };
|
||||
}
|
||||
|
||||
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
const { url, depth: depthArg, pages: pagesArg, mode, userContent } = parseBenchmarkArgs(arg);
|
||||
if (!url) {
|
||||
chunk(view, `사용법: \`/benchmark <url> [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
|
||||
chunk(view, [
|
||||
'사용법: `/benchmark <url> [mode] [depth=N] [pages=N] [보조 설명]`',
|
||||
'- mode: `full`(기본 — 디자인+비즈니스) · `design`(복원 시방서만) · `biz`(사업 분석만) · `build`(풀코스+빌드 프롬프트) · `handoff`(클라우드 개발용 인수인계 문서 — LLM 없이 즉시)',
|
||||
'- 가격·수익 분석이 목적이면 `depth=2` 로 pricing 페이지까지 크롤 권장',
|
||||
'예: `/benchmark https://interiorai.com build depth=2 pages=12 나는 부동산 쪽으로 변형하고 싶어`',
|
||||
'',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
const userContent = restParts.join(' ');
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const crawlDepth = depthArg ?? (cfg.get<number>('datacollectCrawlDepth', 1) ?? 1);
|
||||
@@ -126,23 +167,84 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
// ── handoff 모드: LLM 합성을 건너뛰고 스캔 원시 데이터로 인수인계 문서를 즉시 생성.
|
||||
// 소형 모델의 요약 병목을 통과시키지 않는다 (측정치 무손실 — handoffDoc.ts 참조).
|
||||
if (mode === 'handoff') {
|
||||
const handoffDoc = buildHandoffDoc(s, url, { crawlDepth, maxPages, userContent });
|
||||
chunk(view, `📦 **개발 인수인계 문서 생성** (LLM 미개입 — 측정치 원본)\n\n`);
|
||||
const hToday = new Date().toISOString().slice(0, 10);
|
||||
let hHost = url;
|
||||
try { hHost = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw */ }
|
||||
const hTitle = `웹벤치마크 핸드오프 ${hHost} ${hToday}`;
|
||||
const savedH = await persistWikiDoc(hTitle, handoffDoc, { docType: 'benchmark-handoff', sources: [url] });
|
||||
chunk(view, describeSave(savedH, '인수인계 문서'));
|
||||
if (savedH.ok && savedH.path) {
|
||||
chunk(view, [
|
||||
'',
|
||||
'**다음 단계 — 클라우드 모델로 개발:**',
|
||||
`1. 파일을 열어 §1 "내 요구사항" 을 채우세요 (무엇으로 변형할지).`,
|
||||
`2. Claude Code(구독 인증)로 바로 개발: \`/claude ${savedH.path} 파일을 읽고 명세대로 개발해줘\``,
|
||||
` (또는 Antigravity 에 파일 내용을 붙여넣어도 됩니다)`,
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let finalReport: string;
|
||||
if (looksEmpty) {
|
||||
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
|
||||
finalReport = rawReport;
|
||||
} else {
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
|
||||
const partList = BENCHMARK_MODE_PARTS[mode];
|
||||
const PART_LABELS: Record<number, string> = {
|
||||
1: '4-렌즈 디자인', 2: 'IA·토큰', 3: '재구축 명세', 4: '비즈니스 렌즈', 5: '빌드 프롬프트',
|
||||
};
|
||||
chunk(view, `🧪 **LLM 합성** (mode \`${mode}\` · ${partList.length}단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
|
||||
try {
|
||||
const parts: string[] = [];
|
||||
for (const part of [1, 2, 3] as const) {
|
||||
chunk(view, `\n · 합성 ${part}/3 진행 중…`);
|
||||
let okParts = 0;
|
||||
for (let i = 0; i < partList.length; i++) {
|
||||
const part = partList[i];
|
||||
chunk(view, `\n · ${i + 1}/${partList.length} ${PART_LABELS[part]} 합성 중…`);
|
||||
const partT0 = Date.now();
|
||||
const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
|
||||
if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
|
||||
// [v2.2.307] 파트별 실패 격리 — 한 파트가 죽어도 완성된 파트를 버리지 않는다
|
||||
// (실사례: 파트 2 타임아웃으로 105초 걸린 파트 1까지 폐기). 타임아웃도 300초로 —
|
||||
// 입력 JSON 이 큰 파트는 e4b 급에서 120초를 넘긴다.
|
||||
let out: string;
|
||||
try {
|
||||
out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part), undefined, { timeoutMs: 300_000 });
|
||||
} catch (partErr: any) {
|
||||
chunk(view, ` ✗ (${Math.round((Date.now() - partT0) / 1000)}s — ${partErr?.message || partErr})`);
|
||||
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: ${partErr?.message || String(partErr)} — 나머지 파트는 계속 진행됨.`);
|
||||
continue;
|
||||
}
|
||||
if (!out) {
|
||||
chunk(view, ` ✗ (빈 응답)`);
|
||||
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: 빈 응답 — 나머지 파트는 계속 진행됨.`);
|
||||
continue;
|
||||
}
|
||||
okParts++;
|
||||
// [Report QA] 비즈니스 렌즈는 날조 위험(임의 가격·매출)이 가장 큰 파트 —
|
||||
// 채점→피드백 재작성→회귀 게이트를 통과시킨다. prompt 로 합성 프롬프트
|
||||
// 자체(스캔 JSON 포함)를 넘겨 재작성이 원본 근거를 보게 한다.
|
||||
if (part === 4 && getConfig().reportQaEnabled) {
|
||||
chunk(view, ` (품질 검증 중…)`);
|
||||
const qa = await runReportQaLoop({
|
||||
draft: out,
|
||||
prompt: buildSynthesisPrompt(s, userContent, 4),
|
||||
intentBrief: null,
|
||||
model,
|
||||
onProgress: (msg: string) => chunk(view, ` · ${msg}`),
|
||||
});
|
||||
out = qa.finalDraft;
|
||||
logInfo('[Benchmark] 비즈니스 렌즈 QA 완료.', { score: qa.score, revisions: qa.revisions });
|
||||
}
|
||||
parts.push(out);
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
|
||||
}
|
||||
if (okParts === 0) throw new Error('모든 합성 파트가 실패했습니다.');
|
||||
finalReport = parts.join('\n\n---\n\n');
|
||||
chunk(view, `\n\n`);
|
||||
} catch (e: any) {
|
||||
@@ -151,6 +253,10 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
|
||||
}
|
||||
}
|
||||
|
||||
// [v2.2.307] 레이아웃 골격 (결정론) — 합성 성공/실패와 무관하게 항상 포함.
|
||||
// "배너/캐러셀이 있는가, 각 페이지가 어떤 블록 순서로 구성되는가"에 즉답.
|
||||
finalReport = `${buildLayoutSkeleton(s)}\n\n---\n\n${finalReport}`;
|
||||
|
||||
chunk(view, finalReport + '\n\n');
|
||||
|
||||
try {
|
||||
@@ -1129,7 +1235,7 @@ async function reduceReviewNotes(
|
||||
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
|
||||
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
|
||||
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
|
||||
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
|
||||
registerSlashCommand({ name: '/benchmark', description: '웹 벤치마크 — 디자인 4-렌즈 + 비즈니스 렌즈(가격·수익·운영) 분석, build 모드는 스텝별 빌드 프롬프트까지', handler: runBenchmark });
|
||||
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
|
||||
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
|
||||
registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
|
||||
|
||||
@@ -21,6 +21,10 @@ import * as vscode from 'vscode';
|
||||
* LM 서버(OpenAI 호환 `/v1/chat/completions`)를 확장에서 직접 호출. LM Studio/Ollama 는
|
||||
* 인증이 없으므로 토큰 불필요. 타임아웃 가드 포함, 비정상 응답이면 throw.
|
||||
*/
|
||||
// [코어 수렴 예외 — 의도적] 이 모듈은 core/services.coreChat 으로 수렴하지 않는다:
|
||||
// finish_reason 기반 이어쓰기(continuation), repeat_penalty/top_k 커스텀 샘플링,
|
||||
// degeneration 감지·재시도 등 '장문 생성 엔진' 요구가 코어 채팅 API 범위를 벗어난다.
|
||||
// 단순 단발 완성이 필요한 새 코드는 이 파일이 아니라 coreChat 을 사용할 것.
|
||||
async function lmChat(lmUrl: string, payload: unknown, timeoutMs = 120_000): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
@@ -70,6 +74,9 @@ interface LmOpts {
|
||||
/** 출력이 length(토큰 상한)로 잘렸을 때 끊긴 지점부터 이어쓰기 최대 횟수(기본 3). 0이면 이어쓰기 안 함.
|
||||
* 회의록 "누락" 의 실제 원인(출력 잘림)을 보완 — 잘린 부분을 이어붙여 완결성을 확보. */
|
||||
maxContinuations?: number;
|
||||
/** 호출당 타임아웃(ms). 기본 120초 — /benchmark 4파트 합성처럼 입력 JSON 이 큰
|
||||
* 호출은 e4b 급 모델에서 120초를 넘길 수 있어 (실사례: 파트 2 abort) 크게 지정. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** 단발 호출 결과 — 이어쓰기 판단을 위해 finish_reason 을 함께 반환. */
|
||||
@@ -82,6 +89,7 @@ async function callLmOnce(
|
||||
lmUrl: string, model: string, sys: string, prompt: string,
|
||||
sampling: { temperature: number; repeat_penalty: number; top_k: number },
|
||||
maxTokens?: number,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<LmResult> {
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -96,7 +104,7 @@ async function callLmOnce(
|
||||
};
|
||||
// max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해.
|
||||
if (maxTokens && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
const res = await lmChat(lmUrl, payload, 120_000);
|
||||
const res = await lmChat(lmUrl, payload, timeoutMs);
|
||||
const choice = res?.choices?.[0];
|
||||
const content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? '';
|
||||
const finish = String(choice?.finish_reason ?? choice?.native_finish_reason ?? '').toLowerCase();
|
||||
@@ -146,7 +154,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
|
||||
top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10
|
||||
};
|
||||
try {
|
||||
const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens);
|
||||
const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens, opts?.timeoutMs);
|
||||
let out = first.text;
|
||||
if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; }
|
||||
if (looksDegenerate(out)) {
|
||||
@@ -159,7 +167,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
|
||||
for (let c = 0; c < maxCont && finish === 'length'; c++) {
|
||||
let next: LmResult;
|
||||
try {
|
||||
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens);
|
||||
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens, opts?.timeoutMs);
|
||||
} catch { break; } // 이어쓰기 호출 실패 시 지금까지분으로 마감
|
||||
if (!next.text || looksDegenerate(next.text)) break; // 이어쓰기가 붕괴하면 중단
|
||||
out = `${out}\n${next.text}`.trim();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* /benchmark handoff — 클라우드 모델(Claude Code·Antigravity 등) 개발 인수인계 문서.
|
||||
*
|
||||
* 설계 원칙 (v2.2.303): 로컬 소형 모델의 *작문*을 통과시키지 않는다.
|
||||
* 4b 모델이 큰 모델에게 줄 지시문을 요약하게 하면 스캔이 정확히 측정한 값
|
||||
* (rgb·px·카피 원문)이 "적절한 색상" 같은 모호어로 퇴화한다. 클라우드 모델은
|
||||
* 원시 구조화 데이터를 그대로 소화하는 걸 가장 잘하므로, 이 문서는 스캔 JSON 을
|
||||
* 결정론적으로 추려 옮기는 **순수 함수**다 — LLM 호출 0회, 손실 0.
|
||||
*
|
||||
* 산출물 구성: ①클라우드 모델용 지시문 ②내 요구사항 슬롯(사용자 변형 지시)
|
||||
* ③디자인 토큰 원본 ④마이크로카피 원문 ⑤IA/페이지 인벤토리 ⑥원시 JSON 부록.
|
||||
*/
|
||||
|
||||
const j = (v: unknown): string => JSON.stringify(v, null, 2);
|
||||
|
||||
/** 값이 있으면 "- **라벨**: 값" 한 줄, 없으면 빈 문자열. */
|
||||
function line(label: string, v: unknown): string {
|
||||
if (v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)) return '';
|
||||
const s = Array.isArray(v) ? v.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(', ') : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
||||
return `- **${label}**: ${s}`;
|
||||
}
|
||||
|
||||
function section(title: string, lines: string[]): string {
|
||||
const body = lines.filter(Boolean).join('\n');
|
||||
return body ? `### ${title}\n${body}` : `### ${title}\n- (스캔 데이터 없음)`;
|
||||
}
|
||||
|
||||
export function buildHandoffDoc(scan: any, url: string, opts: { crawlDepth: number; maxPages: number; userContent: string }): string {
|
||||
const s = scan || {};
|
||||
const colors = s.design?.colors || {};
|
||||
const typo = s.design?.typography || {};
|
||||
const layout = s.design?.layout || {};
|
||||
const inter = s.interactions || {};
|
||||
const micro = s.microcopy || {};
|
||||
const pages: any[] = s.sitemap?.pages || [];
|
||||
|
||||
const pageRows = pages.map((p: any) => {
|
||||
const forms = (p.formFields || []).slice(0, 4).map((f: any) => f.name || f.label || f.type).filter(Boolean).join('·') || '-';
|
||||
const ctas = (p.ctaSamples || []).slice(0, 3).join(' / ') || '-';
|
||||
return `| ${p.url || '-'} | ${p.role || '-'} | ${(p.h1 || p.title || '-').slice(0, 40)} | ${p.primaryContentType || '-'} | ${p.imageCount ?? '-'} | ${forms} | ${ctas} |`;
|
||||
}).join('\n');
|
||||
|
||||
const requirements = opts.userContent.trim()
|
||||
? `사용자가 지정한 변형 방향:\n> ${opts.userContent.trim()}\n\n위 방향에 맞춰 아래 원본 명세를 변형하되, 디자인 토큰·레이아웃 골격은 유지하라.`
|
||||
: [
|
||||
'아직 지정되지 않음 — 개발 시작 전에 아래를 채워 넣을 것:',
|
||||
'- [ ] 서비스 이름 / 도메인:',
|
||||
'- [ ] 헤드라인을 무엇으로 바꿀지 (원본의 포지셔닝 공식은 유지):',
|
||||
'- [ ] 콘텐츠·이미지는 무엇으로 대체할지:',
|
||||
'- [ ] 뺄 기능 (결제·로그인 등 MVP 에서 제외할 것):',
|
||||
'- [ ] 핵심 생성/처리 엔진 (사용할 API 또는 로컬 모델):',
|
||||
].join('\n');
|
||||
|
||||
return `# 개발 인수인계 — ${s.meta?.title || url}
|
||||
|
||||
> 이 문서는 Playwright 스캔이 **측정한 원시 값**을 그대로 옮긴 개발 명세입니다.
|
||||
> 생성: Astra /benchmark handoff · 원본 ${url} · 스캔 ${s.sitemap?.totalPages ?? 1}페이지 (depth ${opts.crawlDepth})
|
||||
|
||||
## 0. 클라우드 모델용 지시문 (이 문서를 받은 AI 에게)
|
||||
|
||||
너는 아래 명세를 근거로 원본 사이트와 매우 유사한 사이트를 처음부터 구현한다.
|
||||
1. **모든 색상·폰트·여백·radius 값은 이 문서의 측정치를 그대로 사용하라** — "비슷한 색", "모던한 폰트" 같은 임의 대체 금지.
|
||||
2. §1 "내 요구사항"의 변경 지시가 원본 명세와 충돌하면 요구사항이 우선한다. 그 외에는 원본 재현이 기본.
|
||||
3. 페이지 구조는 §4 인벤토리의 역할(role)·섹션 순서를 따른다.
|
||||
4. 기술 스택은 특별한 지시가 없으면 정적 웹 + 최소 백엔드(Next.js 또는 Node/Express)로 단순하게.
|
||||
5. 명세에 없는 정보(백엔드 로직·CMS·결제 연동)는 추측하지 말고 구현 전에 질문하라.
|
||||
|
||||
## 1. 내 요구사항 (변형 지시)
|
||||
|
||||
${requirements}
|
||||
|
||||
## 2. 디자인 토큰 (측정 원본)
|
||||
|
||||
${section('컬러', [
|
||||
line('팔레트 (사용 빈도순)', (colors.palette || []).slice(0, 8).map((p: any) => `${p.value}(×${p.count})`)),
|
||||
line('배경', colors.background),
|
||||
line('본문 텍스트', colors.primaryText),
|
||||
line('링크', colors.linkColor),
|
||||
line('버튼 배경', colors.buttonBackground),
|
||||
line('버튼 텍스트', colors.buttonText),
|
||||
line('구성 비율', colors.composition?.ratioLabel),
|
||||
line('다크모드 신호', colors.darkModeHints),
|
||||
])}
|
||||
|
||||
${section('타이포그래피', [
|
||||
line('주 폰트', typo.primaryFont),
|
||||
line('폰트 스택', (typo.fontStack || []).slice(0, 3)),
|
||||
line('h1', typo.h1),
|
||||
line('h2', typo.h2),
|
||||
line('h3', typo.h3),
|
||||
line('본문', typo.body),
|
||||
line('버튼', typo.button),
|
||||
line('상위 크기', (typo.topFontSizes || []).slice(0, 6)),
|
||||
line('상위 굵기', (typo.topFontWeights || []).slice(0, 5)),
|
||||
])}
|
||||
|
||||
${section('레이아웃 & 여백', [
|
||||
line('본문 최대 폭', layout.bodyMaxWidth),
|
||||
line('섹션 간 여백', layout.sectionSpacing),
|
||||
line('카드 간격', layout.cardSpacing),
|
||||
line('radius 스케일', layout.borderRadiusScale),
|
||||
line('그리드', layout.grids),
|
||||
line('컨테이너', layout.containerSystem),
|
||||
line('반응형 신호', layout.responsiveHints),
|
||||
])}
|
||||
|
||||
${section('인터랙션', [
|
||||
line('hover 규칙', (inter.hoverRules || []).slice(0, 6)),
|
||||
line('focus 규칙', (inter.focusRules || []).slice(0, 3)),
|
||||
line('transition 분포', inter.transitionDistribution),
|
||||
line('CSS 변수', inter.cssVars),
|
||||
])}
|
||||
|
||||
## 3. 마이크로카피 원문 (톤앤매너의 근거 — 변형 시에도 이 공식·리듬 유지)
|
||||
|
||||
${[
|
||||
line('헤드라인', micro.headline),
|
||||
line('서브헤드라인', micro.subheadline || (micro.subheadlines || []).slice(0, 4)),
|
||||
line('CTA 샘플', (micro.ctaSamples || []).slice(0, 8)),
|
||||
line('placeholder', micro.placeholders),
|
||||
line('상태 메시지', micro.stateMessages),
|
||||
line('본문 샘플', typeof micro.bodySample === 'string' ? micro.bodySample.slice(0, 300) : micro.bodySample),
|
||||
].filter(Boolean).join('\n') || '- (스캔 데이터 없음)'}
|
||||
|
||||
## 4. 페이지 인벤토리 (IA)
|
||||
|
||||
\`\`\`
|
||||
${String(s.sitemap?.ascii || '(단일 페이지)').trimEnd()}
|
||||
\`\`\`
|
||||
|
||||
| URL | 역할 | 제목/H1 | 콘텐츠 유형 | 이미지 수 | 폼 필드 | CTA |
|
||||
|---|---|---|---|---|---|---|
|
||||
${pageRows || '| (단일 페이지 스캔) | - | - | - | - | - | - |'}
|
||||
|
||||
## 5. 원시 스캔 데이터 부록 (기계 판독용 — 위 요약에 없는 세부값은 여기서 인용)
|
||||
|
||||
\`\`\`json
|
||||
${j({
|
||||
meta: s.meta,
|
||||
design: s.design,
|
||||
interactions: s.interactions,
|
||||
microcopy: s.microcopy,
|
||||
structure: s.structure,
|
||||
}).slice(0, 60_000)}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 레이아웃 골격 리포트 — 스캔 데이터에서 **결정론으로** 페이지별 위→아래 구성을 그린다.
|
||||
*
|
||||
* 배경 (v2.2.307): /benchmark 결과가 "색·폰트" 수준에 머물고, LLM 합성이 죽으면
|
||||
* 레이아웃 정보가 아예 사라졌다 (실사례: koritips 스캔 — 파트 2 타임아웃 → 원시
|
||||
* 요약만 남음). 골격(어떤 섹션이 어떤 순서로, 배너/캐러셀/폼 위젯이 어디 있는지)은
|
||||
* 스캔의 sectionRoles(태그 + class/id 힌트 + 미리보기)에서 LLM 없이 뽑을 수 있다.
|
||||
* → 이 모듈이 항상 보고서에 포함되므로 합성이 실패해도 골격 분석은 남는다.
|
||||
*/
|
||||
|
||||
/** class/id 힌트 → 위젯 종류 추정 (단일 권위 — 새 신호는 여기만 추가). */
|
||||
export const WIDGET_SIGNALS: ReadonlyArray<{ re: RegExp; label: string }> = [
|
||||
{ re: /carousel|swiper|slick|slider|slideshow|glide|splide/i, label: '🎠 캐러셀/롤링 배너' },
|
||||
{ re: /hero|jumbotron|masthead|banner|billboard/i, label: '🖼️ 히어로/배너' },
|
||||
{ re: /nav|menu|gnb|header/i, label: '🧭 내비게이션/헤더' },
|
||||
{ re: /footer/i, label: '🦶 푸터' },
|
||||
{ re: /sidebar|aside|widget-area/i, label: '📌 사이드바' },
|
||||
{ re: /search/i, label: '🔍 검색' },
|
||||
{ re: /subscribe|newsletter|cta|signup/i, label: '📮 구독/CTA' },
|
||||
{ re: /grid|card|list|archive|loop|posts/i, label: '🗂️ 카드/리스트 그리드' },
|
||||
{ re: /comment/i, label: '💬 댓글' },
|
||||
{ re: /related|recommend/i, label: '🔗 관련 글' },
|
||||
{ re: /breadcrumb/i, label: '🧵 브레드크럼' },
|
||||
{ re: /pagination|paging|pager/i, label: '📄 페이지네이션' },
|
||||
];
|
||||
|
||||
export function classifyWidget(hint: string): string | null {
|
||||
const h = (hint || '').trim();
|
||||
if (!h) return null;
|
||||
for (const { re, label } of WIDGET_SIGNALS) if (re.test(h)) return label;
|
||||
return null;
|
||||
}
|
||||
|
||||
function sectionLine(sec: any, idx: number): string {
|
||||
const tag = sec.tag || sec.role || 'section';
|
||||
const hint = String(sec.hint || '').slice(0, 40);
|
||||
const widget = classifyWidget(hint);
|
||||
const preview = String(sec.preview || sec.text || sec.textPreview || '').replace(/\s+/g, ' ').slice(0, 60);
|
||||
const counts = [
|
||||
sec.btns != null || sec.buttonCount != null ? `버튼 ${sec.btns ?? sec.buttonCount}` : '',
|
||||
sec.imgs != null || sec.imgCount != null ? `이미지 ${sec.imgs ?? sec.imgCount}` : '',
|
||||
sec.links != null || sec.linkCount != null ? `링크 ${sec.links ?? sec.linkCount}` : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
return [
|
||||
`${idx + 1}. \`<${tag}>\``,
|
||||
widget ? widget : '',
|
||||
hint ? `\`${hint}\`` : '',
|
||||
preview ? `— "${preview}"` : '',
|
||||
counts ? `(${counts})` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지별 위→아래 골격 마크다운. 스캔이 비어도 깨지지 않는다.
|
||||
* LLM 합성 성공/실패와 무관하게 보고서에 항상 포함할 것.
|
||||
*/
|
||||
export function buildLayoutSkeleton(scan: any): string {
|
||||
const s = scan || {};
|
||||
const out: string[] = ['## 레이아웃 골격 (스캔 실측 — LLM 미개입)', ''];
|
||||
|
||||
// 홈(루트) — structure.sections 가 가장 상세.
|
||||
const rootSections: any[] = s.structure?.sections || [];
|
||||
if (rootSections.length) {
|
||||
out.push(`### 홈 \`/\` — 위→아래 블록 구성`);
|
||||
rootSections.slice(0, 12).forEach((sec, i) => out.push(`- ${sectionLine(sec, i)}`));
|
||||
out.push('');
|
||||
}
|
||||
|
||||
// 하위 페이지 — sitemap.pages[].sectionRoles.
|
||||
const pages: any[] = (s.sitemap?.pages || []).filter((p: any) => !p?.error);
|
||||
for (const p of pages) {
|
||||
const roles: any[] = p.sectionRoles || [];
|
||||
const pathLabel = (() => { try { return new URL(p.url, 'https://x').pathname; } catch { return p.url || '?'; } })();
|
||||
if (pathLabel === '/' && rootSections.length) continue; // 홈은 위에서 상세 처리
|
||||
if (!roles.length) continue;
|
||||
out.push(`### \`${decodeURIComponent(pathLabel)}\` (${p.role || '?'} · ${p.primaryContentType || '?'})`);
|
||||
roles.slice(0, 10).forEach((sec, i) => out.push(`- ${sectionLine(sec, i)}`));
|
||||
out.push('');
|
||||
}
|
||||
|
||||
// 감지된 위젯 총괄 — "롤링 배너가 있다/없다"에 바로 답하는 요약.
|
||||
const allHints: string[] = [
|
||||
...rootSections.map((x: any) => String(x.hint || '')),
|
||||
...pages.flatMap((p: any) => (p.sectionRoles || []).map((x: any) => String(x.hint || ''))),
|
||||
];
|
||||
const found = new Set<string>();
|
||||
for (const h of allHints) { const w = classifyWidget(h); if (w) found.add(w); }
|
||||
const carousel = [...found].some(w => w.includes('캐러셀'));
|
||||
out.push('### 위젯 감지 요약');
|
||||
out.push(`- 감지됨: ${found.size ? [...found].join(', ') : '(없음)'}`);
|
||||
out.push(`- 롤링 배너/캐러셀: ${carousel ? '**있음** (class/id 신호 기준)' : '신호 없음 — 스캔 힌트에 캐러셀 계열 class 가 없었음 (JS 렌더 위젯일 수 있어 실사이트 확인 권장)'}`);
|
||||
|
||||
if (!rootSections.length && !pages.some((p: any) => (p.sectionRoles || []).length)) {
|
||||
return '## 레이아웃 골격 (스캔 실측)\n- 섹션 데이터가 스캔에 없습니다 (depth/pages 를 올려 재스캔 권장).';
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
/** /benchmark 보고서의 3 파트 분할 — 1: 4-렌즈 / 2: IA + 토큰 / 3: 재구축 명세. */
|
||||
export type SynthesisPart = 1 | 2 | 3;
|
||||
/**
|
||||
* /benchmark 보고서의 파트 분할.
|
||||
* 1: 4-렌즈 디자인 분석 / 2: IA + 디자인 토큰 / 3: 원본 재구축 명세 (재해석 금지)
|
||||
* 4: 비즈니스 벤치마킹 렌즈 (v2.2.302 — 포지셔닝·가격·수익구조·운영 방향성.
|
||||
* 피터 레벨스 류 1인 AI 기업 분석 프레임 이식. 여기서만 사용자 컨텍스트로 변형 제안 허용)
|
||||
* 5: 단계별 빌드 프롬프트 (v2.2.302 — 재구축 명세를 바이브코딩 IDE 에 바로
|
||||
* 붙여넣는 스텝별 프롬프트 교재로 변환)
|
||||
*/
|
||||
export type SynthesisPart = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
/**
|
||||
* scan JSON → 4-렌즈 분석 LLM 프롬프트. Datacollect 웹앱(WebBenchmarkPanel)의
|
||||
@@ -149,19 +156,24 @@ ${commonHeader}
|
||||
### 1-2. 타이포그래피 (Typography)
|
||||
|
||||
## 2. 레이아웃 및 여백 (Layout & Whitespace)
|
||||
### 2-1. 그리드 시스템 (Grid System)
|
||||
### 2-2. 섹션 간 여백 (Section Spacing)
|
||||
### 2-3. 카드/카드 그리드 (Card Spacing)
|
||||
### 2-4. Border Radius / 컨테이너
|
||||
### 2-1. 페이지 골격 서술 (Page Skeleton — 위→아래)
|
||||
- \`structure.sections\` 와 \`sitemap.pages[].sectionRoles\` 를 근거로, 홈과 대표 페이지의 블록 구성을 **위→아래 순서 문장**으로 서술하라 (예: "헤더/GNB → 히어로 배너 → 최신 글 카드 그리드(이미지 40장) → 구독 폼 → 푸터").
|
||||
- 각 섹션의 \`hint\`(class/id) 신호로 위젯을 판별하라: carousel/swiper/slick/slider = 롤링 배너, hero/banner = 정적 배너, grid/card/list = 카드 그리드 등. **배너가 롤링(캐러셀)인지 정적인지 반드시 판정**하고, 신호가 없으면 "캐러셀 class 신호 없음 — 정적으로 추정"이라고 명시하라 (지어내기 금지).
|
||||
### 2-2. 그리드 시스템 (Grid System)
|
||||
### 2-3. 섹션 간 여백 (Section Spacing)
|
||||
### 2-4. 카드/카드 그리드 (Card Spacing)
|
||||
### 2-5. Border Radius / 컨테이너
|
||||
|
||||
## 3. 마이크로 인터랙션 (Micro Interaction)
|
||||
### 3-1. Hover / Focus 효과
|
||||
### 3-2. Transition 패턴
|
||||
### 3-3. 레이어링 (z-index / position)
|
||||
|
||||
## 4. 라이팅 톤앤매너 (Microcopy & Voice)
|
||||
### 4-1. 헤드라인 / 서브헤드라인 / CTA 카피
|
||||
### 4-2. Placeholder 및 보이스 신호`
|
||||
## 4. 톤앤매너 (Tone & Manner)
|
||||
### 4-1. 시각적 톤앤매너 한 문단 (Visual Personality)
|
||||
- 컬러 비율·폰트·radius·여백 수치를 종합해 이 사이트의 시각적 성격을 한 문단으로 규정하라 (예: "무채색 80% + 하늘색 포인트, 넉넉한 여백, 산세리프 — 정보 전달 중심의 차분한 블로그 톤"). 수치를 인용하되 성격 규정은 문장으로.
|
||||
### 4-2. 헤드라인 / 서브헤드라인 / CTA 카피
|
||||
### 4-3. Placeholder 및 보이스 신호`
|
||||
: part === 2
|
||||
? `
|
||||
## 5. 정보 구조 / 사이트 맵 (Information Architecture)
|
||||
@@ -199,7 +211,7 @@ ${commonHeader}
|
||||
- **재사용 컴포넌트**: 5-5에서 정의한 컴포넌트 이름을 콤마로 나열.
|
||||
|
||||
표 아래에 각 템플릿을 ATag/CSS 명세 수준으로 풀어 쓰는 짧은 단락을 덧붙여도 좋다 (선택).`
|
||||
: `
|
||||
: part === 3 ? `
|
||||
## 9. 원본 사이트 재구축 명세 (Rebuild Spec — Same Site, Built From Scratch)
|
||||
|
||||
> **⚠️ 이 단계의 미션 (절대 이탈 금지)**
|
||||
@@ -230,13 +242,70 @@ ${commonHeader}
|
||||
|
||||
- 스캔으로는 잡히지 않는 영역(다이나믹 데이터·CMS 구조·실제 폰트 라이선스·결제 연동 등)을 나열. 추측이 필요한 부분만 적고, 임의로 결정하지 말 것.
|
||||
|
||||
> **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`;
|
||||
> **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`
|
||||
: part === 4 ? `
|
||||
## 10. 서비스 정의 & 포지셔닝 (What Are They Selling)
|
||||
### 10-1. 한 줄 포지셔닝 분석
|
||||
- 헤드라인(\`microcopy.headline\`)이 어떤 공식인지 분석하라 (예: "Fire your photographer" = <대체 대상> + <도발적 동사> 공식). 카피 원문을 인용할 것.
|
||||
### 10-2. 타깃 사용자와 핵심 가치 제안
|
||||
- CTA·서브헤드라인·폼 필드에서 역추적한 타깃 고객과, 이 서비스가 대체하는 기존 비용(사람/시간/돈).
|
||||
### 10-3. 서비스 유형 분류
|
||||
- 생성형 AI 도구 / SaaS / 콘텐츠 / 커머스 중 무엇이며, 핵심 엔진(이미지 생성·텍스트 생성 등)은 무엇으로 추정되는지. 근거 문구 인용.
|
||||
|
||||
## 11. 가격 & 수익 구조 (Pricing & Unit Economics)
|
||||
### 11-1. 가격 티어 추출
|
||||
- 스캔 데이터(h2List·ctaSamples·페이지 텍스트)에서 발견한 가격·크레딧·구독 문구를 **원문 그대로** 표로 정리. 가격 정보가 스캔에 없으면 "스캔 데이터 부족 — \`depth=2\` 로 pricing 페이지 포함 재스캔 권장"이라고 쓰고 이 섹션을 추정으로 채우지 마라.
|
||||
### 11-2. AI 원가 추정 (표)
|
||||
- 이 서비스의 핵심 생성 1회당 원가를 시나리오 표로: (a) 상용 API 사용 시 (일반적으로 알려진 단가 범위 — 반드시 "추정"으로 표기), (b) 오픈소스 모델 셀프호스팅 시 (GPU 서버 고정비 방식). 두 방식의 손익분기 조건을 "사용자 N명 × 월 생성 M회" 형태의 부등식으로 제시.
|
||||
### 11-3. 마진 시뮬레이션
|
||||
- 판매가 − AI 원가 − 결제 수수료(~3.3%) 산술을 표로. 스캔에서 가격을 못 찾았으면 가상의 판매가라고 명시하고 계산 구조만 보여라.
|
||||
|
||||
## 12. 기능 인벤토리 & 사용자 여정 (Feature Inventory)
|
||||
### 12-1. 사용자 여정 지도
|
||||
- 폼 필드·CTA·페이지 흐름에서 역추적: 방문 → (예: 업로드 → 스타일 선택 → 생성 → 결과 → 결제/구독) 단계별로. 각 단계의 근거 데이터를 인용.
|
||||
### 12-2. 백엔드 요구 기능
|
||||
- 인증 / 결제(어떤 PG 흔적이 있는지) / 생성 큐 / 스토리지 / 크레딧 차감 등 프론트 뒤에 있어야 할 기능 목록.
|
||||
### 12-3. MVP 컷 (2~3일 버전)
|
||||
- "빠르게 만들어 시장 반응을 본다" 원칙으로, 위 기능 중 첫 배포에 꼭 필요한 것만 남긴 최소 범위. 뺄 것을 명시하는 것이 핵심.
|
||||
|
||||
## 13. 운영 & 방향성 제안 (Operations & Direction)
|
||||
> 이 섹션에서만 [사용자 보조 컨텍스트]를 적극 결합해 변형·확장을 제안하라 (part 3의 원본 복원 원칙과 분리된 목적).
|
||||
### 13-1. 시리즈화 아이디어
|
||||
- 이 서비스의 공식(포지셔닝+엔진)을 유지한 채 겉만 재포장할 수 있는 인접 서비스 3개 (예: "Fire your X" 시리즈).
|
||||
### 13-2. 광고비 0원 마케팅 채널
|
||||
- 이 유형의 서비스가 초기 사용자를 모으는 현실적 채널 2~3개와 각 채널에 맞는 첫 게시물 아이디어.
|
||||
### 13-3. 출시 후 검증 지표
|
||||
- 시장 반응을 판단할 최소 지표 (방문→업로드 전환율, 생성→결제 전환율 등)와 "접을 기준"까지.
|
||||
### 13-4. 사용자 컨텍스트 결합 변형
|
||||
- [사용자 보조 컨텍스트]가 있으면 그 방향으로 이 모델을 변형한 구체안 1개. 없으면 "(컨텍스트 미입력)" 한 줄만.`
|
||||
: `
|
||||
## 14. 단계별 빌드 프롬프트 (Step-by-Step Build Prompts)
|
||||
|
||||
> **미션**: 아래 스텝 프롬프트들을 바이브코딩 IDE 에 순서대로 붙여넣기만 하면 원본과 매우 유사한 서비스가 완성되어야 한다.
|
||||
> - 각 스텝은 \`### STEP N: <제목>\` 헤딩 + 프롬프트 전문을 담은 코드블록 1개.
|
||||
> - 프롬프트 안에 스캔에서 추출한 **실제 디자인 토큰 값**(색상 rgb/hex·폰트·radius·spacing)과 헤드라인·CTA 원문을 그대로 박아 넣어라 — "적절한 색"·"모던한 폰트" 같은 모호어 금지.
|
||||
> - 각 프롬프트는 자기완결적: 이전 스텝의 산출물을 전제하되, 참조하는 값은 다시 명시한다.
|
||||
> - 기술 스택은 특별한 근거가 없으면 정적 웹 + 최소 백엔드(Node/Express 또는 Next.js) 로 단순하게.
|
||||
|
||||
### STEP 1: 랜딩 페이지 (헤드라인·히어로·가격 섹션 — 디자인 토큰 포함)
|
||||
### STEP 2: 핵심 도구 UI (업로드/입력 → 옵션 선택 → 결과 표시 골격, API 미연결 상태)
|
||||
### STEP 3: 생성 API 연동 (.env 키 보호, 로딩·에러 상태, 결과 다운로드)
|
||||
### STEP 4: 결제/크레딧 최소 구현 (또는 대기자 명단 폼 — MVP 컷 기준)
|
||||
|
||||
각 STEP 아래에 "확인 방법" 한 줄(브라우저에서 무엇이 보이면 성공인지)을 덧붙여라.
|
||||
|
||||
## 15. 빌드 순서 요약 (Build Order)
|
||||
- STEP 1→4 를 표로: | 스텝 | 산출물 | 소요 추정 | 확인 방법 |.`;
|
||||
|
||||
const partGoal = part === 1
|
||||
? '1/3단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
|
||||
? '이번 단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
|
||||
: part === 2
|
||||
? '2/3단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
|
||||
: '3/3단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**';
|
||||
? '이번 단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
|
||||
: part === 3
|
||||
? '이번 단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**'
|
||||
: part === 4
|
||||
? '이번 단계: 이 사이트를 하나의 *사업*으로 분석한다 — 무엇을 팔고, 어떻게 돈을 벌고, 내가 비슷한 서비스를 만든다면 어떻게 운영할지. 스캔 데이터에 있는 문구·가격·CTA 를 근거로 하되, 스캔에 없는 가격·매출은 지어내지 말고 "스캔 데이터 부족"으로 표기한다.'
|
||||
: '이번 단계: 앞선 분석을 바탕으로, 바이브코딩 IDE(Antigravity 등)에 순서대로 붙여넣기만 하면 이 사이트와 매우 유사한 서비스가 만들어지는 **스텝별 빌드 프롬프트**를 작성한다. 각 프롬프트는 자기완결적이어야 한다 (앞 스텝 결과를 전제하되 필요한 값은 다시 명시).';
|
||||
|
||||
return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다.
|
||||
${sharedRules}
|
||||
|
||||
Binary file not shown.
@@ -18,6 +18,7 @@
|
||||
* 수동 명령과 완전 호환.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../../config';
|
||||
@@ -47,23 +48,11 @@ let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
let _lastFiredYmd = '';
|
||||
|
||||
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(new Date());
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
|
||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday: new Date(`${ymd}T00:00:00Z`).getUTCDay() };
|
||||
}
|
||||
|
||||
function cycleSchedule(): { day: number; hour: number; minute: number } {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const day = Math.min(6, Math.max(0, cfg.get<number>('growthCycle.day', 0)));
|
||||
const raw = (cfg.get<string>('growthCycle.time', '20:00') || '20:00').trim();
|
||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||
return { day, hour: m ? Math.min(23, Number(m[1])) : 20, minute: m ? Math.min(59, Number(m[2])) : 0 };
|
||||
const t = parseHhMm(cfg.get<string>('growthCycle.time', '20:00'), 20, 0);
|
||||
return { day, hour: t.hour, minute: t.minute };
|
||||
}
|
||||
|
||||
function msUntilNextFire(): number {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* - 스케줄: 매일 KST 03:00 (기본) — stocksWatcher 와 동일한 setTimeout 체인 패턴.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../../config';
|
||||
@@ -213,19 +214,8 @@ export async function runSleepDigestOnce(): Promise<string> {
|
||||
|
||||
// ── 일일 스케줄러 (KST, stocksWatcher 패턴) ──────────────────────────────────
|
||||
|
||||
function nowInKst(): { hour: number; minute: number; ymd: string } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(new Date());
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd: `${get('year')}-${get('month')}-${get('day')}` };
|
||||
}
|
||||
|
||||
function digestTime(): { hour: number; minute: number } {
|
||||
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00') || '03:00').trim();
|
||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||
return m ? { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) } : { hour: 3, minute: 0 };
|
||||
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00'), 3, 0);
|
||||
}
|
||||
|
||||
function msUntilNextFire(): number {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Claude Code CLI adapter — **구독(Pro/Max) 인증으로 Claude 를 Astra 엔진으로**.
|
||||
*
|
||||
* 배경 (v2.2.305): Anthropic 구독은 API 키가 아니라 Claude Code 로그인 방식이라
|
||||
* `anthropic:` 프로바이더(API 키·종량제)로는 쓸 수 없다. 이 어댑터는 사용자가
|
||||
* 이미 로그인해 둔 로컬 `claude` CLI 를 headless(-p) 로 실행해 추론만 위임한다 —
|
||||
* 추가 과금 없이 구독 한도 안에서 동작.
|
||||
*
|
||||
* 통합 방식: 다른 클라우드 프로바이더와 동일하게 *OpenAI 호환 SSE* Response 를
|
||||
* 반환한다 → agent.ts 의 기존 SSE 파서가 그대로 소비 (모델 드롭다운에
|
||||
* "Claude 구독 · sonnet" 으로 노출, 선택만 하면 됨).
|
||||
*
|
||||
* 설계 결정:
|
||||
* - 프롬프트는 argv 가 아니라 **stdin** 으로 전달 — Astra 의 시스템 프롬프트+
|
||||
* 컨텍스트는 수십 KB 라 ARG_MAX(맥 256KB) 위험이 있다.
|
||||
* - `--output-format stream-json` 을 파싱하되, 버전 간 이벤트 차이를 피하려고
|
||||
* turn 단위(assistant 메시지)로 수집해 SSE 로 흘린다 (부분 델타 플래그는
|
||||
* 구버전 CLI 에 없어 사용하지 않음).
|
||||
* - 실패는 명확한 에러로 — CLI 미설치/미로그인 시 설치·로그인 안내를 에러
|
||||
* 본문에 담는다 (조용한 폴백 금지).
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as vscode from 'vscode';
|
||||
import { StreamParams, ChatMessage } from './types';
|
||||
import { execFileCapture } from '../../lib/execUtil';
|
||||
import { logInfo, logWarn } from '../../utils';
|
||||
|
||||
/** 드롭다운에 노출할 구독 모델 별칭 — CLI 의 --model 이 그대로 받는 값. */
|
||||
export function listClaudeCodeModels(): string[] {
|
||||
return ['sonnet', 'opus', 'haiku'];
|
||||
}
|
||||
|
||||
// ── CLI 경로 탐지 (프로세스 생존 동안 캐시) ─────────────────────────────────
|
||||
let _cliPath: string | null | undefined;
|
||||
|
||||
function candidatePaths(): string[] {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation').get<string>('claude.cliPath', '')?.trim();
|
||||
const home = os.homedir();
|
||||
const cands = [
|
||||
...(cfg ? [cfg] : []),
|
||||
'claude',
|
||||
'/usr/local/bin/claude',
|
||||
'/opt/homebrew/bin/claude',
|
||||
`${home}/.claude/local/claude`,
|
||||
`${home}/.npm-global/bin/claude`,
|
||||
...(process.platform === 'win32' ? ['claude.cmd', 'claude.exe'] : []),
|
||||
];
|
||||
return [...new Set(cands)];
|
||||
}
|
||||
|
||||
/** 로그인된 Claude Code CLI 실행 파일을 찾는다. null = 미설치 (설치 안내는 호출자). */
|
||||
export async function resolveClaudeCli(): Promise<string | null> {
|
||||
if (_cliPath !== undefined) return _cliPath;
|
||||
for (const cand of candidatePaths()) {
|
||||
// 절대 경로 후보는 존재 확인으로 빠르게 거르고, PATH 후보는 --version 실행으로 검증.
|
||||
if (cand.includes('/') || cand.includes('\\')) {
|
||||
try { if (!fs.existsSync(cand)) continue; } catch { continue; }
|
||||
}
|
||||
const r = await execFileCapture(cand, ['--version'], { timeoutMs: 8_000 });
|
||||
if (r.code === 0) {
|
||||
_cliPath = cand;
|
||||
logInfo('[ClaudeCode] CLI 확인.', { path: cand, version: r.stdout.trim().slice(0, 40) });
|
||||
return cand;
|
||||
}
|
||||
}
|
||||
_cliPath = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 테스트/설정 변경 시 캐시 초기화. */
|
||||
export function _resetClaudeCliCache(): void { _cliPath = undefined; }
|
||||
|
||||
// ── 프롬프트 조립 (순수 — 테스트 대상) ──────────────────────────────────────
|
||||
/**
|
||||
* Astra 의 messages 배열을 claude -p 가 받는 단일 프롬프트로 평탄화.
|
||||
* 시스템 프롬프트는 명시적 블록으로, 대화 이력은 화자 라벨로 보존한다.
|
||||
*/
|
||||
export function flattenClaudePrompt(messages: ChatMessage[]): string {
|
||||
const sys: string[] = [];
|
||||
const turns: string[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role === 'system') sys.push(m.content);
|
||||
else turns.push(`${m.role === 'user' ? '[사용자]' : '[아스트라]'}\n${m.content}`);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (sys.length) {
|
||||
parts.push(`<system_instructions>\n${sys.join('\n\n')}\n</system_instructions>`);
|
||||
parts.push('위 system_instructions 를 너의 시스템 지침으로 따르라. 도구를 사용하지 말고, 마지막 [사용자] 메시지에 대한 답변 본문만 출력하라.');
|
||||
}
|
||||
if (turns.length) parts.push(turns.join('\n\n'));
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/** CLI 인자 (순수 — 테스트 대상). 프롬프트는 stdin 으로 가므로 여기 없음. */
|
||||
export function buildClaudeCliArgs(model: string): string[] {
|
||||
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||||
const m = (model || '').trim();
|
||||
if (m) args.push('--model', m);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** stream-json 라인들에서 답변 텍스트/에러 추출 (순수 — 테스트 대상). */
|
||||
export function extractFromStreamJson(lines: string[]): { text: string; error: string } {
|
||||
let text = '';
|
||||
let error = '';
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith('{')) continue;
|
||||
let j: any;
|
||||
try { j = JSON.parse(t); } catch { continue; }
|
||||
if (j.type === 'assistant' && Array.isArray(j.message?.content)) {
|
||||
for (const block of j.message.content) {
|
||||
if (block?.type === 'text' && typeof block.text === 'string') text += block.text;
|
||||
}
|
||||
} else if (j.type === 'result') {
|
||||
if (j.is_error || (j.subtype && j.subtype !== 'success')) {
|
||||
error = String(j.result || j.error || j.subtype || 'unknown error');
|
||||
} else if (!text && typeof j.result === 'string') {
|
||||
text = j.result; // 일부 버전은 assistant 이벤트 없이 result 에만 본문
|
||||
}
|
||||
}
|
||||
}
|
||||
return { text, error };
|
||||
}
|
||||
|
||||
// ── 실행기 ──────────────────────────────────────────────────────────────────
|
||||
const LOGIN_HINT = 'Claude Code CLI 확인이 필요합니다 — 터미널에서 `claude` 실행 후 구독 계정으로 로그인하세요. 미설치라면 `npm install -g @anthropic-ai/claude-code`. (경로가 특이하면 설정 g1nation.claude.cliPath 지정)';
|
||||
|
||||
export interface ClaudeCodeResult { text: string }
|
||||
|
||||
/** claude -p 1회 실행 — 성공 시 답변 텍스트, 실패 시 throw(안내 포함 메시지). */
|
||||
export async function runClaudeCode(params: {
|
||||
messages: ChatMessage[];
|
||||
model: string;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
}): Promise<ClaudeCodeResult> {
|
||||
const cli = await resolveClaudeCli();
|
||||
if (!cli) throw new Error(LOGIN_HINT);
|
||||
|
||||
const prompt = flattenClaudePrompt(params.messages);
|
||||
const args = buildClaudeCliArgs(params.model);
|
||||
const cwd = vscode.workspace.workspaceFolders?.find(f => f.uri.scheme === 'file')?.uri.fsPath || os.homedir();
|
||||
const timeoutMs = params.timeoutMs ?? 300_000; // 클라우드 대형 모델 — 여유 있게
|
||||
|
||||
return new Promise<ClaudeCodeResult>((resolve, reject) => {
|
||||
const child = spawn(cli, args, { cwd, windowsHide: true });
|
||||
let out = '';
|
||||
let err = '';
|
||||
let settled = false;
|
||||
const finish = (fn: () => void) => { if (!settled) { settled = true; clearTimeout(timer); fn(); } };
|
||||
const timer = setTimeout(() => {
|
||||
try { child.kill(); } catch { /* already dead */ }
|
||||
finish(() => reject(new Error(`Claude 응답이 ${Math.round(timeoutMs / 1000)}초 안에 오지 않아 중단했습니다.`)));
|
||||
}, timeoutMs);
|
||||
if (params.signal) {
|
||||
const onAbort = () => { try { child.kill(); } catch { /* noop */ } finish(() => reject(new Error('AbortError'))); };
|
||||
if (params.signal.aborted) onAbort();
|
||||
else params.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
child.stdout.on('data', (d) => { out += String(d); });
|
||||
child.stderr.on('data', (d) => { err += String(d); });
|
||||
child.on('error', (e) => finish(() => reject(new Error(`Claude CLI 실행 실패(${e.message}). ${LOGIN_HINT}`))));
|
||||
child.on('close', (code) => finish(() => {
|
||||
const { text, error } = extractFromStreamJson(out.split('\n'));
|
||||
if (text.trim()) return resolve({ text: text.trim() });
|
||||
const detail = (error || err || `exit ${code}`).trim().split('\n').slice(-4).join(' ').slice(0, 400);
|
||||
logWarn('[ClaudeCode] 빈 응답/실패.', { code, detail });
|
||||
reject(new Error(`Claude 응답 실패: ${detail}\n${LOGIN_HINT}`));
|
||||
}));
|
||||
child.stdin.write(prompt);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 스트리밍 어댑터 (OpenAI 호환 SSE Response) ─────────────────────────────
|
||||
export async function streamClaudeCode(
|
||||
_context: vscode.ExtensionContext,
|
||||
params: StreamParams,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const { text } = await runClaudeCode({
|
||||
messages: params.messages,
|
||||
model: params.model,
|
||||
signal: params.signal,
|
||||
});
|
||||
const sse = [
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { content: text }, finish_reason: null }] })}`,
|
||||
'',
|
||||
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}`,
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'', '',
|
||||
].join('\n');
|
||||
return new Response(sse, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
|
||||
} catch (e: any) {
|
||||
if (e?.message === 'AbortError') throw e;
|
||||
return new Response(JSON.stringify({ error: { message: e?.message || String(e) } }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ProviderId, StreamParams, ChatMessage, parseModelPrefix, makeModelId, p
|
||||
import { streamOpenRouter, listOpenRouterModels } from './openrouter';
|
||||
import { streamAnthropic, listAnthropicModels } from './anthropic';
|
||||
import { streamGemini, listGeminiModels } from './gemini';
|
||||
import { streamClaudeCode, listClaudeCodeModels, resolveClaudeCli } from './claudeCode';
|
||||
import { readProviderConfig, writeProviderConfig, readProviderStatus, ProviderConfig } from './providerConfig';
|
||||
|
||||
export type { ProviderId, ChatMessage, StreamParams, ProviderConfig };
|
||||
@@ -31,6 +32,7 @@ export async function streamCloudCompletion(
|
||||
case 'openrouter': return streamOpenRouter(context, fullParams);
|
||||
case 'anthropic': return streamAnthropic(context, fullParams);
|
||||
case 'gemini': return streamGemini(context, fullParams);
|
||||
case 'claude-code': return streamClaudeCode(context, fullParams);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +66,13 @@ export async function listAllCloudModels(context: vscode.ExtensionContext): Prom
|
||||
for (const id of ids) out.push({ id: makeModelId('gemini', id), provider: 'gemini', label: `Gemini · ${id}` });
|
||||
}));
|
||||
}
|
||||
// Claude 구독 — API 키 불필요: 로그인된 Claude Code CLI 가 있으면 곧 활성.
|
||||
tasks.push(resolveClaudeCli().then((cli) => {
|
||||
if (!cli) return;
|
||||
for (const id of listClaudeCodeModels()) {
|
||||
out.push({ id: makeModelId('claude-code', id), provider: 'claude-code', label: `Claude 구독 · ${id}` });
|
||||
}
|
||||
}).catch(() => { /* 탐지 실패 = 미노출 */ }));
|
||||
await Promise.all(tasks);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* - 옛 사용자 model 설정 ('gemma4:e2b') 은 prefix 없으니 자동으로 local 경로.
|
||||
*/
|
||||
|
||||
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini';
|
||||
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini' | 'claude-code';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
@@ -32,6 +32,8 @@ const PROVIDER_PREFIXES: ReadonlyArray<{ prefix: string; id: ProviderId }> = [
|
||||
{ prefix: 'openrouter:', id: 'openrouter' },
|
||||
{ prefix: 'anthropic:', id: 'anthropic' },
|
||||
{ prefix: 'gemini:', id: 'gemini' },
|
||||
// Claude 구독(Pro/Max) — API 키가 아니라 로컬 Claude Code CLI 로그인에 위임 (v2.2.305).
|
||||
{ prefix: 'claude:', id: 'claude-code' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -48,14 +50,18 @@ export function parseModelPrefix(modelId: string): { provider: ProviderId; model
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 역변환 — provider + 실제 model → prefixed id. UI / config 저장용. */
|
||||
/** 역변환 — provider + 실제 model → prefixed id. UI / config 저장용.
|
||||
* 주의: prefix 는 PROVIDER_PREFIXES 가 단일 권위 — provider id 와 다를 수 있다
|
||||
* (claude-code 의 prefix 는 사용자 친화적으로 'claude:'). */
|
||||
export function makeModelId(provider: ProviderId, model: string): string {
|
||||
return `${provider}:${model}`;
|
||||
const hit = PROVIDER_PREFIXES.find(p => p.id === provider);
|
||||
return `${hit ? hit.prefix : provider + ':'}${model}`;
|
||||
}
|
||||
|
||||
/** UI 표시용 짧은 라벨. */
|
||||
export function providerLabel(p: ProviderId): string {
|
||||
switch (p) {
|
||||
case 'claude-code': return 'Claude 구독';
|
||||
case 'openrouter': return 'OpenRouter';
|
||||
case 'anthropic': return 'Anthropic';
|
||||
case 'gemini': return 'Gemini';
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
* - 워크스페이스 외부 경로 무시
|
||||
* - 사용자가 `executionVerification=false`면 통째로 skip — 호출자가 가드
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import { detectPythonSync, PYTHON_CANDIDATES } from '../../lib/execUtil';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
@@ -86,23 +87,10 @@ function _firstNonEmptyLine(s: string): string {
|
||||
* Returns the platform-preferred default when neither is found so the caller
|
||||
* still produces the usual "미설치" warning via the spawn-failure path.
|
||||
*/
|
||||
let _pythonCmdCache: string | undefined;
|
||||
// [코어 수렴] Python 탐지는 lib/execUtil 의 단일 구현·공유 캐시를 쓴다.
|
||||
// 미설치 시에도 플랫폼 기본 후보를 돌려줘 spawn-실패 경로가 기존처럼 "미설치" 경고를 만든다.
|
||||
function _resolvePythonCmd(): string {
|
||||
if (_pythonCmdCache !== undefined) return _pythonCmdCache;
|
||||
const candidates = process.platform === 'win32'
|
||||
? ['python', 'python3']
|
||||
: ['python3', 'python'];
|
||||
for (const cmd of candidates) {
|
||||
try {
|
||||
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
|
||||
if (!r.error && (r.status === 0 || r.status === null)) {
|
||||
_pythonCmdCache = cmd;
|
||||
return cmd;
|
||||
}
|
||||
} catch { /* try next candidate */ }
|
||||
}
|
||||
_pythonCmdCache = candidates[0];
|
||||
return _pythonCmdCache;
|
||||
return detectPythonSync() ?? PYTHON_CANDIDATES[0];
|
||||
}
|
||||
|
||||
/** 확장자별 검사 명령 결정. 지원 안 하는 확장자면 null 반환 (skip). */
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* - 시스템 Python 자체 설치는 OS 정책상 자동화 위험 → python.org 링크 안내만.
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import { PYTHON_CANDIDATES } from '../../lib/execUtil';
|
||||
import { spawn } from 'child_process';
|
||||
import { logInfo, logError } from '../../utils';
|
||||
|
||||
@@ -44,9 +45,9 @@ export async function probePythonEnv(): Promise<PythonProbe> {
|
||||
};
|
||||
|
||||
// Windows 는 `python` / `py` 가 보통 우선, 그 외엔 `python3` 가 안전.
|
||||
const candidates = process.platform === 'win32'
|
||||
? ['python', 'py', 'python3']
|
||||
: ['python3', 'python'];
|
||||
// [코어 수렴] 후보 순서는 lib/execUtil 과 공유 (이 probe 는 Python3 버전·패키지 검증까지
|
||||
// 수행하는 특수 경로라 탐지 로직 자체는 유지한다).
|
||||
const candidates = PYTHON_CANDIDATES;
|
||||
|
||||
for (const cmd of candidates) {
|
||||
const ver = await _capture(cmd, ['--version'], 5_000);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { nowInKst } from '../../lib/kstSchedule';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { readStocksStore, writeStocksStore } from './stocksStore';
|
||||
import { fetchAllPrices } from './yahooClient';
|
||||
@@ -32,23 +33,6 @@ function scheduleTimesKst(): { hour: number; minute: number }[] {
|
||||
let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
|
||||
/** Asia/Seoul 기준 *지금* 의 hour/minute. */
|
||||
function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } {
|
||||
const now = new Date();
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(now);
|
||||
const get = (t: string) => Number(parts.find(p => p.type === t)?.value || '0');
|
||||
return {
|
||||
date: now,
|
||||
hour: get('hour'),
|
||||
minute: get('minute'),
|
||||
ymd: `${get('year')}-${parts.find(p => p.type === 'month')?.value}-${parts.find(p => p.type === 'day')?.value}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금부터 다음 firing 까지 milliseconds 계산.
|
||||
* SCHEDULE_HOURS_KST 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각.
|
||||
|
||||
+13
-30
@@ -1,3 +1,4 @@
|
||||
import { coreChat } from '../core/services';
|
||||
/**
|
||||
* 단순 non-streaming LLM 호출 — Ollama / LM Studio(OpenAI 호환) 듀얼 엔드포인트.
|
||||
*
|
||||
@@ -19,34 +20,16 @@ export async function simpleChatCompletion(
|
||||
user: string,
|
||||
options: SimpleChatOptions,
|
||||
): Promise<string> {
|
||||
const isOllama = options.baseUrl.includes(':11434') || options.baseUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${options.baseUrl}/api/chat` : `${options.baseUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 120000);
|
||||
try {
|
||||
const messages = [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
];
|
||||
const body = isOllama
|
||||
? { model: options.model, stream: false, messages, options: { temperature: options.temperature ?? 0.2, num_predict: options.maxTokens ?? 1200 } }
|
||||
: { model: options.model, stream: false, temperature: options.temperature ?? 0.2, max_tokens: options.maxTokens ?? 1200, messages };
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
return String(
|
||||
data?.message?.content ??
|
||||
data?.choices?.[0]?.message?.content ??
|
||||
data?.choices?.[0]?.text ??
|
||||
data?.response ??
|
||||
'',
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — core/services.coreChat 으로 통일.
|
||||
// options.baseUrl 은 하위 호환용으로 남지만 무시된다 (코어가 설정의 ollamaUrl 사용 —
|
||||
// 기존 호출자들도 전부 config.ollamaUrl 을 넘기고 있었으므로 동작 동일).
|
||||
const result = await coreChat({
|
||||
system,
|
||||
user,
|
||||
model: options.model,
|
||||
timeoutMs: options.timeoutMs ?? 120000,
|
||||
temperature: options.temperature ?? 0.2,
|
||||
maxTokens: options.maxTokens ?? 1200,
|
||||
});
|
||||
return result.content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* LiveReasoningFilter — 스트리밍 *도중* 모델의 내부 추론을 화면에서 차단하는
|
||||
* 상태 기계 (per-generation 1 인스턴스, stateful).
|
||||
*
|
||||
* 배경: liveStreamTokens=true(기본) 라이브 스트리밍에서 `<|channel|>thought …` /
|
||||
* `<think>…</think>` 같은 추론 구간이 생성 내내 그대로 노출됐다가 완료 시
|
||||
* streamReplace 로만 사라졌다. 사용자가 볼 필요 없는 텍스트가 답변 대기 내내
|
||||
* 화면을 채우는 문제 — 이 필터가 토큰 단위로 추론 구간을 걸러 "보여도 되는"
|
||||
* 델타만 통과시킨다. 최종 정리는 여전히 outputSanitization(streamReplace)이
|
||||
* 책임지므로, 여기서 놓친 마커는 완료 시 반드시 정리된다 (이중 방어).
|
||||
*
|
||||
* 처리하는 마커 (outputSanitization.sanitizeAssistantContent 와 동일 계열):
|
||||
* - `<think>…</think>` / `<thinking>…</thinking>` / `<analysis>…</analysis>`
|
||||
* - Harmony 채널: `<|channel|>thought|analysis|commentary|reasoning …` 는 숨기고,
|
||||
* `<|channel|>final` (+ 뒤따르는 `<|message|>`) 마커는 제거 후 본문 통과.
|
||||
* `<|end|>` / `<|return|>` 는 숨김 종료.
|
||||
*
|
||||
* 토큰 경계 대응: 마커가 토큰 두 개에 걸쳐 쪼개져 와도 (`<thi` + `nk>`) 잡히도록,
|
||||
* 열림 마커의 접두사가 될 수 있는 꼬리는 emit 을 보류(holdback)한다.
|
||||
* "Thinking Process:" 류 평문 휴리스틱은 라이브에서는 오탐 위험이 커서 다루지
|
||||
* 않는다 — 완료 시 streamReplace 가 정리.
|
||||
*/
|
||||
|
||||
const TAG_OPENERS: { literal: string; closer: RegExp }[] = [
|
||||
{ literal: '<think>', closer: /<\/think(?:ing)?>/i },
|
||||
{ literal: '<thinking>', closer: /<\/think(?:ing)?>/i },
|
||||
{ literal: '<analysis>', closer: /<\/analysis>/i },
|
||||
];
|
||||
|
||||
/** Harmony 채널 마커 — `<|channel|>` 외에 `<channel|>` / `<|channel>` 변형도 (sanitize 와 동일). */
|
||||
const CHANNEL_MARKER = /<\|?channel\|?>/i;
|
||||
const HIDDEN_CHANNEL_WORD = /^(?:thought|analysis|commentary|reasoning)\b/i;
|
||||
const FINAL_CHANNEL_WORD = /^final\b/i;
|
||||
/** 채널 이름 판별에 필요한 최대 대기 글자수 — 마커 뒤 공백+가장 긴 채널명이면 충분. */
|
||||
const CHANNEL_PEEK_CHARS = 16;
|
||||
const END_MARKER = /<\|?(?:end|return)\|?>/i;
|
||||
const MESSAGE_MARKER = /^\s*<\|?message\|?>/i;
|
||||
|
||||
/** tail 이 열림 마커들 중 하나의 접두사가 될 *가능성* 이 있으면 true (emit 보류 판단). */
|
||||
function couldBeOpenerPrefix(tail: string): boolean {
|
||||
if (!tail.startsWith('<')) return false;
|
||||
const lower = tail.toLowerCase();
|
||||
for (const { literal } of TAG_OPENERS) {
|
||||
if (literal.startsWith(lower)) return true;
|
||||
}
|
||||
// 채널 마커 변형들의 접두사 여부
|
||||
for (const marker of ['<|channel|>', '<channel|>', '<|channel>']) {
|
||||
if (marker.startsWith(lower)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type Mode = 'visible' | 'hiddenTag' | 'hiddenChannel' | 'channelPending';
|
||||
|
||||
export class LiveReasoningFilter {
|
||||
private mode: Mode = 'visible';
|
||||
/** 아직 판정/방출하지 않은 원본 꼬리. */
|
||||
private pending = '';
|
||||
/** hiddenTag 모드에서 기다리는 닫힘 패턴. */
|
||||
private closer: RegExp | null = null;
|
||||
|
||||
/**
|
||||
* 토큰을 누적하고, 화면에 내보내도 안전한 텍스트 델타를 반환한다.
|
||||
* 반환값이 빈 문자열이면 이번 토큰은 전부 (아직) 숨김.
|
||||
*/
|
||||
push(token: string): string {
|
||||
this.pending += token;
|
||||
let out = '';
|
||||
|
||||
// 상태 전이가 한 토큰 안에서 여러 번 일어날 수 있어 (열림+닫힘 동시 도착) 루프.
|
||||
for (;;) {
|
||||
if (this.mode === 'visible') {
|
||||
const hit = this.findEarliestOpener(this.pending);
|
||||
if (hit) {
|
||||
out += this.pending.slice(0, hit.index);
|
||||
this.pending = this.pending.slice(hit.index + hit.length);
|
||||
if (hit.kind === 'tag') {
|
||||
this.mode = 'hiddenTag';
|
||||
this.closer = hit.closer!;
|
||||
} else {
|
||||
this.mode = 'channelPending';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 열림 마커 없음 — 꼬리가 마커의 접두사일 수 있으면 그만큼 보류하고 방출.
|
||||
const hold = this.holdbackLen(this.pending);
|
||||
out += this.pending.slice(0, this.pending.length - hold);
|
||||
this.pending = hold ? this.pending.slice(-hold) : '';
|
||||
return out;
|
||||
}
|
||||
|
||||
if (this.mode === 'channelPending') {
|
||||
// 채널 이름이 판별될 만큼 모일 때까지 대기.
|
||||
const head = this.pending.replace(/^\s+/, '');
|
||||
if (FINAL_CHANNEL_WORD.test(head)) {
|
||||
// final 채널 — 마커(+뒤따르는 <|message|>)만 제거하고 본문은 통과.
|
||||
let rest = head.replace(FINAL_CHANNEL_WORD, '');
|
||||
const m = rest.match(MESSAGE_MARKER);
|
||||
if (m) rest = rest.slice(m[0].length);
|
||||
else if (/^\s*<?\|?m?e?s?s?a?g?e?\|?>?$/i.test(rest) && rest.length < 12) {
|
||||
// <|message|> 가 아직 다 안 온 것일 수 있음 — 더 기다린다.
|
||||
return out;
|
||||
}
|
||||
this.pending = rest;
|
||||
this.mode = 'visible';
|
||||
continue;
|
||||
}
|
||||
if (HIDDEN_CHANNEL_WORD.test(head)) {
|
||||
this.pending = head;
|
||||
this.mode = 'hiddenChannel';
|
||||
continue;
|
||||
}
|
||||
if (this.pending.length >= CHANNEL_PEEK_CHARS) {
|
||||
// 아는 채널명이 아님 — 보수적으로 숨김 (완료 시 streamReplace 가 정리).
|
||||
this.mode = 'hiddenChannel';
|
||||
continue;
|
||||
}
|
||||
return out; // 더 모일 때까지 대기
|
||||
}
|
||||
|
||||
if (this.mode === 'hiddenTag') {
|
||||
const m = this.pending.match(this.closer!);
|
||||
if (m && m.index !== undefined) {
|
||||
this.pending = this.pending.slice(m.index + m[0].length);
|
||||
this.mode = 'visible';
|
||||
this.closer = null;
|
||||
continue;
|
||||
}
|
||||
// 닫힘이 토큰 경계에 걸칠 수 있으니 꼬리만 남기고 버린다.
|
||||
this.pending = this.pending.slice(-24);
|
||||
return out;
|
||||
}
|
||||
|
||||
// hiddenChannel: 다음 채널 마커(재판정) 또는 end/return(숨김 종료)까지 폐기.
|
||||
const ch = this.pending.match(CHANNEL_MARKER);
|
||||
const end = this.pending.match(END_MARKER);
|
||||
const chIdx = ch?.index ?? -1;
|
||||
const endIdx = end?.index ?? -1;
|
||||
if (chIdx >= 0 && (endIdx < 0 || chIdx < endIdx)) {
|
||||
this.pending = this.pending.slice(chIdx + ch![0].length);
|
||||
this.mode = 'channelPending';
|
||||
continue;
|
||||
}
|
||||
if (endIdx >= 0) {
|
||||
this.pending = this.pending.slice(endIdx + end![0].length);
|
||||
this.mode = 'visible';
|
||||
continue;
|
||||
}
|
||||
this.pending = this.pending.slice(-24);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** 스트림 종료 시 보류 중이던 visible 꼬리를 회수 (숨김 모드였다면 버린다). */
|
||||
flush(): string {
|
||||
const tail = this.mode === 'visible' ? this.pending : '';
|
||||
this.pending = '';
|
||||
return tail;
|
||||
}
|
||||
|
||||
private findEarliestOpener(s: string): { index: number; length: number; kind: 'tag' | 'channel'; closer?: RegExp } | null {
|
||||
const lower = s.toLowerCase();
|
||||
let best: { index: number; length: number; kind: 'tag' | 'channel'; closer?: RegExp } | null = null;
|
||||
for (const { literal, closer } of TAG_OPENERS) {
|
||||
const i = lower.indexOf(literal);
|
||||
if (i >= 0 && (!best || i < best.index)) best = { index: i, length: literal.length, kind: 'tag', closer };
|
||||
}
|
||||
const m = s.match(CHANNEL_MARKER);
|
||||
if (m && m.index !== undefined && (!best || m.index < best.index)) {
|
||||
best = { index: m.index, length: m[0].length, kind: 'channel' };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private holdbackLen(s: string): number {
|
||||
// 뒤에서부터 '<' 를 찾아, 거기부터 끝까지가 열림 마커의 접두사일 수 있으면 보류.
|
||||
const maxHold = Math.min(s.length, 12);
|
||||
for (let k = 1; k <= maxHold; k++) {
|
||||
const tail = s.slice(s.length - k);
|
||||
if (tail.startsWith('<') && couldBeOpenerPrefix(tail)) return k;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -428,7 +428,17 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
// 지식 스코프와 무관하게(에이전트 스코프가 있어도) 행동 제약으로 항상 주입.
|
||||
const routingHint = buildDomainRoutingHint(classifyKnowledgeDomain(deps.currentPrompt));
|
||||
|
||||
const constraintBlock = [routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
||||
// [답변 전 이해 원칙] 문자적 해석 방지 — 모든 실질 질문 턴에 주입되는 행동 제약
|
||||
// (LLM 호출 없음). 조사·보고서 워크플로우의 '의도 브리핑'(LLM 1회)의 경량판으로,
|
||||
// 일반 단발 질문에서도 "왜 묻는지"를 먼저 판단하고 답하게 한다. (v2.2.301)
|
||||
const intentPrinciple = [
|
||||
'[답변 전 이해 원칙]',
|
||||
'- 이번 메시지를 문자 그대로만 읽지 말고, 최근 대화 흐름·직전 결론과 함께 해석하라.',
|
||||
'- 답하기 전에 판단하라: 이 사람이 왜 지금 이걸 묻는가(목적), 표면 질문 뒤에 정말 궁금한 것은 무엇인가, 답을 받아 무엇을 하려는가.',
|
||||
'- 첫 문장은 그 목적에 바로 답하는 결론으로 시작하라. 해석이 갈리면 가장 가능성 높은 해석으로 답하되, 어떤 해석으로 답했는지 한 줄로 밝혀라.',
|
||||
].join('\n');
|
||||
|
||||
const constraintBlock = [intentPrinciple, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
||||
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
|
||||
return memoryBlock;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,13 @@ export function shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boole
|
||||
// 이 게이트는 fraction 안전 체크보다 *먼저* 평가됨 — 사용자가 절대 임계값을
|
||||
// 명시한 의도(50k 미만은 한 번에 처리)를 fraction 이 뒤집지 못하게. 작은
|
||||
// 컨텍스트 모델 사용자는 config 에서 이 값을 모델 윈도우의 ~30% 로 낮춰야 함.
|
||||
// [v2.2.301] 명시적 조사·보고서 요청은 절대 임계값 게이트보다 우선 발동 —
|
||||
// 짧은 "X 조사해줘"도 Report QA 파이프라인(의도 브리핑→채점→회귀 게이트)을
|
||||
// 타야 하기 때문. '요약/리뷰' 류 일반 키워드는 기존 결정대로 게이트 아래 유지.
|
||||
if (/(조사|리서치|보고서|레포트)/.test(prompt) || /\b(research|report)\b/i.test(prompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const promptTokensForGate = estimateTokens(prompt);
|
||||
if (promptTokensForGate < cfg.chunkedSwitchTokens) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Tone Guard — AI 상투어(클리셰) 금지 레지스트리 (v2.2.306).
|
||||
*
|
||||
* 배경: 잡담 턴에서 "힘든 감정을 느끼고 계시군요. 괜찮으시다면 … 저는 언제든
|
||||
* 여기에 있습니다." 같은 상담사 화법이 그대로 나왔다 (실사례). 작업 턴에는
|
||||
* 문체 규칙(persona R1~R7)이 있지만 casual 모드는 무방비였다.
|
||||
*
|
||||
* 설계: 블로그_v3 QA 레지스트리와 같은 철학 — 금지 표현은 이 배열이 단일 권위.
|
||||
* 소형 모델은 "자연스럽게 해" 같은 추상 지시보다 **구체적 금지 목록 + 대조
|
||||
* 예시**에 훨씬 잘 반응한다. 새 클리셰 발견 시 여기 한 곳만 추가하면 된다.
|
||||
* (순수 모듈 — import 없음: utils/persona 와 contextBuilders 양쪽에서 안전하게 사용)
|
||||
*/
|
||||
|
||||
/** 금지 표현 패턴 — 프롬프트에 나열되는 문구. 발견 즉시 다른 말로 바꿔야 한다. */
|
||||
export const AI_CLICHE_PATTERNS: readonly string[] = [
|
||||
'"~하시군요/~계시군요" 감정 미러링 (예: "슬픈 감정을 느끼고 계시군요")',
|
||||
'"괜찮으시다면/원하신다면 ~해 드릴 수 있습니다" 허락 구걸형 제안',
|
||||
'"저는 언제든 여기에 있습니다" / "언제든 말씀해 주세요" 대기 선언',
|
||||
'"무엇을 도와드릴까요?" 로 대화 던지기 (사용자가 화제를 닫기 전에는 금지)',
|
||||
'"함께 ~해 보아요" / "함께 검토해 드릴 수 있습니다" 류 유도 문구',
|
||||
'"물론입니다!" / "당연하죠!" 과잉 맞장구 서두',
|
||||
'"도움이 되었기를 바랍니다" 류 맺음 인사',
|
||||
'"충분히 그러실 수 있습니다" / "그런 기분이 드는 것은 자연스러운 일입니다" 감정 정당화 공식',
|
||||
];
|
||||
|
||||
/**
|
||||
* 시스템 프롬프트 주입 블록. casual(잡담) 모드와 기본 페르소나 양쪽에서 사용.
|
||||
* 짧게 유지 — 잡담 턴은 컨텍스트가 얇을수록 좋다.
|
||||
*/
|
||||
export function buildToneGuardBlock(): string {
|
||||
return [
|
||||
'[말투 — AI 상투어 금지]',
|
||||
'아래 표현(과 그 변형)은 어떤 턴에서도 쓰지 마라. 하나라도 쓰면 답변 실패다:',
|
||||
...AI_CLICHE_PATTERNS.map(p => `- ${p}`),
|
||||
'',
|
||||
'대신: 같이 일하는 동료의 말투로, 담백한 존댓말. 감정적인 말에는 상담사 공식이 아니라 사람의 짧은 반응 한두 문장 — 필요하면 자연스럽게 하나만 되묻기. 오버해서 친한 척도 하지 마라.',
|
||||
'예시 — 사용자: "오늘 참 기분이 슬퍼"',
|
||||
' 나쁜 답: "힘든 감정을 느끼고 계시군요. 괜찮으시다면 이야기하거나 프로젝트를 검토해 드릴 수 있습니다. 저는 언제든 여기에 있습니다." (금지 표현 3개)',
|
||||
' 좋은 답: "그런 날이 있죠. 무슨 일 있었어요? 얘기해도 되고, 그냥 다른 걸 하면서 잊어도 됩니다."',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { exec, execFile, spawnSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* [코어 수렴] 외부 프로세스 실행 공용 유틸.
|
||||
*
|
||||
* 배경(2026-07-11 아키텍처 감사): execFile/exec 의 Promise 래퍼가 6곳에,
|
||||
* Python 인터프리터 탐지가 3벌 각각 구현되어 타임아웃·OS 대응·에러 시맨틱이
|
||||
* 파일마다 달랐다. 이 모듈이 유일한 구현이며, 새 코드는 자체 래퍼를 만들지 말 것.
|
||||
*
|
||||
* 예외로 남긴 것: selfReflectorExecution 의 _runCheck(스트리밍 spawn — stdout 을
|
||||
* 실시간 누적)와 datacollectSetup 의 패키지 probe(파이썬 3 버전 검증 + import 검사)는
|
||||
* 요구가 달라 유지하되, 인터프리터 후보/탐지는 이 모듈을 공유한다.
|
||||
*/
|
||||
|
||||
export interface ExecCapture {
|
||||
/** 종료 코드. 실행 자체가 실패(spawn 불가)면 -1. */
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
/** 타임아웃으로 강제 종료됨. */
|
||||
timedOut: boolean;
|
||||
/** 명령을 찾지 못하는 등 spawn 자체가 실패. */
|
||||
spawnFailed?: boolean;
|
||||
}
|
||||
|
||||
/** execFile 기반 캡처 실행 — 절대 reject 하지 않고 항상 ExecCapture 를 돌려준다. */
|
||||
export function execFileCapture(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
opts: { timeoutMs?: number; cwd?: string; maxBuffer?: number } = {},
|
||||
): Promise<ExecCapture> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, args, {
|
||||
timeout: opts.timeoutMs ?? 15_000,
|
||||
cwd: opts.cwd,
|
||||
maxBuffer: opts.maxBuffer ?? 1024 * 1024,
|
||||
windowsHide: true,
|
||||
}, (err: any, stdout, stderr) => {
|
||||
resolve({
|
||||
code: err ? (typeof err.code === 'number' ? err.code : -1) : 0,
|
||||
stdout: String(stdout || ''),
|
||||
stderr: String(stderr || ''),
|
||||
timedOut: !!err?.killed,
|
||||
spawnFailed: err?.code === 'ENOENT' || undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 셸 명령 실행 (exec 기반). 실패 시 reject — err.stdout/err.stderr 가 붙는
|
||||
* node 표준 시맨틱 그대로 (health check 의 git 자격증명 판정 등이 의존).
|
||||
*/
|
||||
export function execShell(
|
||||
command: string,
|
||||
opts: { timeoutMs?: number; cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(command, {
|
||||
timeout: opts.timeoutMs ?? 15_000,
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
windowsHide: true,
|
||||
}, (err, stdout, stderr) => {
|
||||
if (err) reject(Object.assign(err, { stdout: String(stdout || ''), stderr: String(stderr || '') }));
|
||||
else resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Python 인터프리터 탐지 (단일 구현·공유 캐시) ────────────────────────────
|
||||
// OS별 후보 순서: 윈도우는 python/py 가 표준, 그 외는 python3 가 안전.
|
||||
export const PYTHON_CANDIDATES: readonly string[] = process.platform === 'win32'
|
||||
? ['python', 'py', 'python3']
|
||||
: ['python3', 'python'];
|
||||
|
||||
let _pythonCmd: string | null | undefined;
|
||||
|
||||
/** 비동기 탐지 — 프로세스 생존 동안 캐시. null = 미설치. */
|
||||
export async function detectPython(): Promise<string | null> {
|
||||
if (_pythonCmd !== undefined) return _pythonCmd;
|
||||
for (const cmd of PYTHON_CANDIDATES) {
|
||||
const r = await execFileCapture(cmd, ['--version'], { timeoutMs: 3_000 });
|
||||
if (r.code === 0) { _pythonCmd = cmd; return cmd; }
|
||||
}
|
||||
_pythonCmd = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 동기 탐지 (spawnSync) — 동기 컨텍스트(selfReflector _pickTool 등)용. 같은 캐시 공유. */
|
||||
export function detectPythonSync(): string | null {
|
||||
if (_pythonCmd !== undefined) return _pythonCmd;
|
||||
for (const cmd of PYTHON_CANDIDATES) {
|
||||
try {
|
||||
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
|
||||
if (!r.error && (r.status === 0 || r.status === null)) { _pythonCmd = cmd; return cmd; }
|
||||
} catch { /* 다음 후보 */ }
|
||||
}
|
||||
_pythonCmd = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 테스트용 — 캐시 초기화. */
|
||||
export function _resetPythonCache(): void { _pythonCmd = undefined; }
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* [코어 수렴] KST(Asia/Seoul) 스케줄 공용 유틸.
|
||||
*
|
||||
* 배경(2026-07-11 아키텍처 감사): stocksWatcher·dailyBriefing·sleepDigest·
|
||||
* growthCycleWatcher 네 워처가 각각 nowInKst()/HH:MM 파싱을 복제하고 있었다.
|
||||
* "다음 발사까지 ms" 계산은 정책이 제각각(복수 시각/평일만/매일/주간)이라
|
||||
* 각 워처에 남기고, 여기서는 그 계산의 공통 원료만 단일화한다.
|
||||
*
|
||||
* 시간대는 항상 Asia/Seoul 강제 — 사용자 OS timezone 과 무관하게 같은 시각에 동작.
|
||||
*/
|
||||
|
||||
export interface KstNow {
|
||||
hour: number;
|
||||
minute: number;
|
||||
/** 'YYYY-MM-DD' — 하루 1회 발사 dedupe 키로 쓰인다. */
|
||||
ymd: string;
|
||||
/** 요일 (0=일 … 6=토). */
|
||||
weekday: number;
|
||||
}
|
||||
|
||||
/** Asia/Seoul 기준 *지금* 의 시각/날짜/요일. */
|
||||
export function nowInKst(): KstNow {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(new Date());
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
|
||||
// 'YYYY-MM-DD' → UTC midnight Date — getUTCDay 가 그 날짜의 요일.
|
||||
const weekday = new Date(`${ymd}T00:00:00Z`).getUTCDay();
|
||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday };
|
||||
}
|
||||
|
||||
/** 'HH:MM' 설정 문자열 파싱 — 형식이 틀리면 기본값, 범위는 23:59 로 클램프. */
|
||||
export function parseHhMm(raw: string | undefined, defHour: number, defMinute: number): { hour: number; minute: number } {
|
||||
const m = (raw || '').trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!m) return { hour: defHour, minute: defMinute };
|
||||
return { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) };
|
||||
}
|
||||
@@ -16,22 +16,12 @@ const DEFAULT_BUDGET: ContextBudgetConfig = {
|
||||
maxChunks: 12
|
||||
};
|
||||
|
||||
/**
|
||||
* 토큰 수를 대략 추정합니다 (문자 수 / 4).
|
||||
* 한국어는 글자당 토큰이 더 많으므로 보정합니다.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
// 한국어 비율 추정
|
||||
const koreanChars = (text.match(/[가-힣]/g) || []).length;
|
||||
const totalChars = text.length;
|
||||
const koreanRatio = totalChars > 0 ? koreanChars / totalChars : 0;
|
||||
// [코어 수렴] 토큰 추정은 lib/contextManager 의 단일 구현을 쓴다 (CJK 전반 커버·
|
||||
// 보수적 계수). 예전 이 파일의 자체 추정(한글만 1.5/0.25)은 제거 — 기존 소비자
|
||||
// 호환을 위해 같은 이름으로 재수출.
|
||||
import { estimateTokens } from '../lib/contextManager';
|
||||
export { estimateTokens };
|
||||
|
||||
// 한국어는 글자당 ~1.5 토큰, 영어는 ~0.25 토큰
|
||||
const koreanTokens = koreanChars * 1.5;
|
||||
const otherTokens = (totalChars - koreanChars) * 0.25;
|
||||
|
||||
return Math.ceil(koreanTokens + otherTokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 청크들을 토큰 예산 내에서 선택합니다.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getConfig } from '../config';
|
||||
import { logInfo } from '../utils';
|
||||
|
||||
/**
|
||||
* 단계별 지식 스코프 라우터 — 소형 모델 정밀 검색의 핵심.
|
||||
@@ -147,8 +148,12 @@ export function resolveDomainScope(query: string, brainRoot: string): DomainScop
|
||||
try { if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) valid.push(abs); } catch { /* skip */ }
|
||||
}
|
||||
// Specialty(마지막 항목)가 살아남지 못했으면 스코프 포기 — General 만으로는 좁히지 않는다.
|
||||
// [조용한 폴백 금지] 설정은 돼 있는데 폴더가 없으면(예: 지식 재편 후 옛 경로) 로그로 드러낸다.
|
||||
const specialtyAbs = path.resolve(rootAbs, relFolders[relFolders.length - 1]);
|
||||
if (!valid.includes(specialtyAbs)) return none;
|
||||
if (!valid.includes(specialtyAbs)) {
|
||||
logInfo('domain-knowledge: Specialty 폴더가 존재하지 않아 전체 두뇌로 폴백합니다. 설정 경로를 확인하세요.', { domain, folder: relFolders[relFolders.length - 1] });
|
||||
return none;
|
||||
}
|
||||
|
||||
return {
|
||||
domain,
|
||||
|
||||
@@ -228,6 +228,42 @@ export class RetrievalOrchestrator {
|
||||
* 으로 반환한다. recall@k / MRR 계산용. 프로덕션 `retrieve()` 와 동일한 scoring 경로
|
||||
* (`searchBrainFiles`) 를 그대로 재사용하므로, 측정값이 실제 검색 동작을 반영한다 (무결성).
|
||||
*/
|
||||
/**
|
||||
* [코어 수렴] 스코프 지정 두뇌 검색 — 기업 모드(dispatcher) 등 "메모리 계층 없이
|
||||
* 두뇌만" 필요한 소비자용 공개 경로. 프로덕션 retrieve() 와 동일한 searchBrainFiles
|
||||
* (임베딩 하이브리드·actionability·청크 검색 포함)를 태우므로, 경량 scopedBrainRetriever
|
||||
* 와 달리 오케스트레이터의 검색 개선을 그대로 받는다.
|
||||
*/
|
||||
public retrieveBrainChunksScoped(
|
||||
query: string,
|
||||
brain: BrainProfile,
|
||||
opts: {
|
||||
limit?: number;
|
||||
scopeFolders?: string[];
|
||||
includeRawConversations?: boolean;
|
||||
queryEmbedding?: number[];
|
||||
embeddingModel?: string;
|
||||
embeddingBlendAlpha?: number;
|
||||
chunkLevelRetrieval?: boolean;
|
||||
chunkTargetChars?: number;
|
||||
} = {},
|
||||
): RetrievalChunk[] {
|
||||
const expandedTokens = expandQuery(tokenize(query));
|
||||
return this.searchBrainFiles(
|
||||
query,
|
||||
expandedTokens,
|
||||
brain,
|
||||
opts.limit ?? 6,
|
||||
opts.includeRawConversations ?? false,
|
||||
opts.scopeFolders ?? [],
|
||||
opts.queryEmbedding,
|
||||
opts.embeddingModel,
|
||||
opts.embeddingBlendAlpha,
|
||||
opts.chunkLevelRetrieval || false,
|
||||
opts.chunkTargetChars ?? 1200,
|
||||
);
|
||||
}
|
||||
|
||||
public rankBrainForEval(
|
||||
query: string,
|
||||
brain: BrainProfile,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
import { coreChat } from '../core/services';
|
||||
|
||||
export interface SemanticRerankOptions {
|
||||
ollamaUrl: string;
|
||||
@@ -94,46 +95,15 @@ async function callLlmForRerank(
|
||||
user: string,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const body = isOllama
|
||||
? {
|
||||
model, stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
options: { temperature: 0.0, num_predict: 256 },
|
||||
}
|
||||
: {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
stream: false, temperature: 0.0, max_tokens: 256,
|
||||
};
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
const content =
|
||||
data?.message?.content ??
|
||||
data?.choices?.[0]?.message?.content ??
|
||||
data?.choices?.[0]?.text ??
|
||||
data?.response ??
|
||||
'';
|
||||
return String(content || '');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — coreChat 으로 통일 (온도 0, 256토큰).
|
||||
const result = await coreChat({
|
||||
system, user, model,
|
||||
timeoutMs,
|
||||
temperature: 0.0,
|
||||
maxTokens: 256,
|
||||
});
|
||||
return String(result.content || '');
|
||||
|
||||
}
|
||||
|
||||
/** LLM 응답에서 ranking 배열 추출 + 검증. 실패 시 null. */
|
||||
|
||||
@@ -23,6 +23,8 @@ import { getConfig } from '../../config';
|
||||
export interface DispatcherDepsInputs {
|
||||
context: vscode.ExtensionContext;
|
||||
ai: AIService;
|
||||
/** [코어 수렴] 오케스트레이터 기반 두뇌 검색 thunk (AgentExecutor.retrieveBrainBlockForCompany). */
|
||||
retrieveBrainContext?: (query: string, scopeFolders: string[], limit: number) => Promise<string>;
|
||||
signal: AbortSignal;
|
||||
onEvent: (event: CompanyTurnEvent) => void;
|
||||
/** Action tag (`<create_file>` 등) 를 실제 파일시스템에 적용. AgentExecutor 의 thunk. */
|
||||
@@ -44,6 +46,7 @@ export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDep
|
||||
return {
|
||||
context: inputs.context,
|
||||
ai: inputs.ai,
|
||||
retrieveBrainContext: inputs.retrieveBrainContext,
|
||||
defaultModel: cfg.defaultModel || 'gemma4:e2b',
|
||||
// Knowledge Mix wiring — 회사 specialists 도 사용자의 Second Brain 을 같은
|
||||
// global 기본값 + per-agent override semantics 로 사용. 없으면 Knowledge Mix
|
||||
|
||||
@@ -1868,6 +1868,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
const deps = buildDispatcherDeps({
|
||||
context: this._context,
|
||||
ai,
|
||||
// [코어 수렴] 기업 모드 검색을 메인 오케스트레이터 경로로 승격.
|
||||
retrieveBrainContext: (q, folders, limit) => this._agent.retrieveBrainBlockForCompany(q, folders, limit),
|
||||
signal: abort.signal,
|
||||
onEvent: emitter.emit,
|
||||
executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { buildToneGuardBlock } from './lib/contextBuilders/toneGuard';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
@@ -282,6 +283,8 @@ R7. GUESS-AND-ACT WITH STATED ASSUMPTION. When information is missing but a reas
|
||||
|
||||
사용자가 명시적으로 화제를 닫지 않는 한 "다음에 무엇을 도와드릴까요?" 류 마무리 금지.
|
||||
|
||||
${buildToneGuardBlock()}
|
||||
|
||||
[ENGINEERING STANCE]
|
||||
- Be a direct engineering partner. Technical precision over polite filler.
|
||||
- Collapse checklists into: verdict → reason → risk → next move. (R1 already requires the verdict to be sentence 1.)
|
||||
|
||||
Reference in New Issue
Block a user