feat(core): 자기지식 접지·웹 접근·환경 자가점검 — 할루시네이션 방어 3중화 (v2.2.247)
- Alignment Self-Learning: 자가 조사(질문 전 두뇌 검색)·사용자 답변 두뇌 저장·핵심메시지/프로젝트 컨텍스트 주입 (alignmentResearch.ts 신규)
- 웹 접근: Bridge 폴백 직접 fetch(webFetch.ts 신규)·<fetch_url> 액션 태그·기업 모드 URL/아키텍처 컨텍스트 주입·bare 도메인 인식
- 트리거 버그 수정: startsWith('/') 가 절대경로를 슬래시 명령으로 오인 — 분석 지시·URL 주입 전멸 원인 (회귀 테스트 고정)
- 자기지식 접지: 기능 인벤토리 lazy 재생성·학습 메커니즘 정본 섹션·[인벤토리 대조] 태그 의무화·결정론적 재구현 제안 정정 훅(featureConceptMap.ts 신규)
- 환경 자가점검: HealthCheckMonitor 에 Bridge/두뇌 볼륨/git 자격증명/확장 버전 검사 4종 + readyBar ⚠ 표시
- 두뇌 동기화: 원격 미설정 시 로컬 새로고침 모드·staged 기준 commit 판정·인증 부재 안내
- 기타: outputFormat 기본 markdown(제목 렌더 복구)·레슨/행동제약 truncation 보호 구역 이동·[CONTEXT] 절단 우선순위 재정렬
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
553aa0b134
commit
a114d968b0
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Alignment Self-Research — 사용자에게 묻기 전에 두뇌를 먼저 검색.
|
||||
*
|
||||
* Intent Alignment 분석기가 만든 openQuestions 를 사용자에게 노출하기 전에,
|
||||
* 활성 두뇌(지식 폴더)를 TF-IDF 로 검색해 *스스로 답할 수 있는 질문* 을 걸러낸다.
|
||||
* 답을 찾은 질문은 answeredQuestions 로 옮겨지고(자가 조사 marker 부착),
|
||||
* 정말 모르는 질문만 사용자에게 도달한다.
|
||||
*
|
||||
* Phase 3(학습 루프): 사용자가 직접 답해준 Q/A 는 두뇌의 전용 폴더에 일반
|
||||
* 노트로 저장된다. 다음 turn 에서 같은 질문이 나오면 이 모듈의 자가 조사가
|
||||
* 그 노트를 발견해 스스로 해결 — 같은 것을 두 번 묻지 않는 구조.
|
||||
*
|
||||
* 설계 원칙 (intentAlignment 와 동일):
|
||||
* - 절대 throw 하지 않는다. 검색/LLM/IO 실패는 "자가 조사 안 한 것"과
|
||||
* 동일하게 동작해야 한다 — alignment 흐름 차단 금지.
|
||||
* - Lesson(Experience Memory) 시스템과 분리 — 그쪽은 "실수 회피" 카드라
|
||||
* 주입 위치/의미가 다르다. 여기 저장물은 평범한 지식 노트.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { IAIService } from '../../core/services';
|
||||
import { findBrainFiles, logError, logInfo } from '../../utils';
|
||||
import { getBrainTokenIndex } from '../../retrieval/brainIndex';
|
||||
import { tokenize, expandQuery, scoreTfIdfPreTokenized, extractBestExcerpt } from '../../retrieval/scoring';
|
||||
|
||||
/**
|
||||
* 자가 조사로 채워진 답변의 식별 prefix. formatContractForPrompt 를 거쳐
|
||||
* dispatcher 의 LLM 에게 그대로 전달되므로, "사용자가 직접 말한 사실"과
|
||||
* 구분되도록 의미를 텍스트 자체에 내장한다. webview 카드 렌더에서도 이
|
||||
* prefix 로 자가 조사 항목을 골라낸다.
|
||||
*/
|
||||
export const SELF_RESEARCH_PREFIX = '(자가 조사로 두뇌에서 확인) ';
|
||||
|
||||
/** 두뇌 저장 폴더 이름 — Phase 3 의 지식 노트가 쌓이는 곳. */
|
||||
export const ALIGNMENT_KNOWLEDGE_DIR = 'Alignment Knowledge';
|
||||
|
||||
/** 질문 1개에 대해 두뇌에서 모은 근거 발췌. */
|
||||
export interface QuestionEvidence {
|
||||
question: string;
|
||||
excerpts: Array<{ title: string; relativePath: string; excerpt: string }>;
|
||||
}
|
||||
|
||||
/** 질문별 검색 상한 — 질문당 top 2 파일, 발췌 600자, 전체 합계 4,000자. */
|
||||
const FILES_PER_QUESTION = 2;
|
||||
const EXCERPT_MAX_CHARS = 600;
|
||||
const TOTAL_EVIDENCE_CAP = 4000;
|
||||
|
||||
/**
|
||||
* openQuestions 각각을 두뇌에 TF-IDF 검색해 근거 발췌를 모은다.
|
||||
* 빈 두뇌 / 잘못된 경로 / IO 에러 → 해당 질문의 excerpts 가 빈 배열 (throw 금지).
|
||||
* 인덱스는 brainIndex 의 mtime 캐시를 그대로 활용하므로 재호출 비용이 낮다.
|
||||
*/
|
||||
export function gatherEvidenceForQuestions(
|
||||
brainPath: string,
|
||||
questions: string[],
|
||||
): QuestionEvidence[] {
|
||||
const empty = questions.map((q) => ({ question: q, excerpts: [] as QuestionEvidence['excerpts'] }));
|
||||
if (!brainPath || questions.length === 0) return empty;
|
||||
try {
|
||||
const files = findBrainFiles(brainPath);
|
||||
if (files.length === 0) return empty;
|
||||
const index = getBrainTokenIndex(brainPath, files);
|
||||
if (index.length === 0) return empty;
|
||||
|
||||
let totalChars = 0;
|
||||
return questions.map((question) => {
|
||||
const queryTokens = expandQuery(tokenize(question));
|
||||
if (queryTokens.length === 0) return { question, excerpts: [] };
|
||||
const scored = scoreTfIdfPreTokenized(queryTokens, index)
|
||||
.filter((s) => s.score > 0)
|
||||
.slice(0, FILES_PER_QUESTION);
|
||||
const excerpts: QuestionEvidence['excerpts'] = [];
|
||||
for (const s of scored) {
|
||||
if (totalChars >= TOTAL_EVIDENCE_CAP) break;
|
||||
const doc = index[s.index];
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(doc.filePath, 'utf8');
|
||||
} catch {
|
||||
continue; // 인덱스에 있는데 디스크에서 사라진 파일 — skip
|
||||
}
|
||||
const excerpt = extractBestExcerpt(content, tokenize(question), EXCERPT_MAX_CHARS);
|
||||
if (!excerpt.trim()) continue;
|
||||
totalChars += excerpt.length;
|
||||
excerpts.push({ title: doc.title, relativePath: doc.relativePath, excerpt });
|
||||
}
|
||||
return { question, excerpts };
|
||||
});
|
||||
} catch (e: any) {
|
||||
logError('alignmentResearch: evidence gathering failed; skipping self-research.', {
|
||||
error: e?.message ?? String(e),
|
||||
});
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
const SELF_ANSWER_SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *자가 조사 판정가*입니다. 사용자에게 질문을 던지기 전에, 두뇌(저장된 지식 노트)에서 검색된 근거만으로 각 질문에 답할 수 있는지 판정합니다.
|
||||
|
||||
규칙:
|
||||
- 근거 발췌에 *명시적으로 적혀 있는* 내용으로만 답하세요. 일반 상식·추측으로 채우는 것은 금지입니다.
|
||||
- 근거가 부분적이거나 모호하면 그 질문은 "unanswered" 입니다. 확신이 없으면 unanswered 가 정답입니다.
|
||||
- answered 인 질문의 answer 는 근거에서 추출한 사실을 2~3문장으로 요약하고, 출처 노트 제목을 괄호로 덧붙이세요.
|
||||
|
||||
⚠️ 반드시 아래 JSON 한 번만 출력. 다른 텍스트(설명·코드펜스·머리말) 일체 금지.
|
||||
|
||||
{
|
||||
"answers": [
|
||||
{ "question": "<원문 그대로>", "status": "answered" | "unanswered", "answer": "<answered 일 때만, 아니면 빈 문자열>" }
|
||||
]
|
||||
}`;
|
||||
|
||||
/** 자가 조사 판정 결과 한 건. */
|
||||
export interface SelfAnswer {
|
||||
question: string;
|
||||
answered: boolean;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 1회 호출로 "근거만으로 답 가능한 질문"을 판정. 호출/파싱 실패 시
|
||||
* 모든 질문을 unanswered 로 반환 — 원래의 질문 카드 흐름이 그대로 동작.
|
||||
*/
|
||||
export async function selfAnswerQuestions(
|
||||
ai: IAIService,
|
||||
input: {
|
||||
userPrompt: string;
|
||||
evidence: QuestionEvidence[];
|
||||
model?: string;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<SelfAnswer[]> {
|
||||
const withEvidence = input.evidence.filter((e) => e.excerpts.length > 0);
|
||||
const fallback = input.evidence.map((e) => ({ question: e.question, answered: false, answer: '' }));
|
||||
if (withEvidence.length === 0) return fallback;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[사용자 원본 요청]');
|
||||
lines.push(input.userPrompt);
|
||||
lines.push('');
|
||||
lines.push('[판정할 질문과 두뇌 검색 근거]');
|
||||
for (const e of withEvidence) {
|
||||
lines.push('');
|
||||
lines.push(`질문: ${e.question}`);
|
||||
for (const x of e.excerpts) {
|
||||
lines.push(`- 근거 (노트: "${x.title}", 경로: ${x.relativePath}):`);
|
||||
lines.push(` ${x.excerpt.replace(/\n/g, '\n ')}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('판정 JSON만 출력:');
|
||||
|
||||
let raw = '';
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: SELF_ANSWER_SYSTEM_PROMPT,
|
||||
user: lines.join('\n'),
|
||||
model: input.model,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
raw = result.content || '';
|
||||
} catch (e: any) {
|
||||
logError('alignmentResearch: self-answer call failed; all questions pass through.', {
|
||||
error: e?.message ?? String(e),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
const parsed = _parseSelfAnswerJson(raw);
|
||||
if (!parsed) {
|
||||
logInfo('alignmentResearch: self-answer parse failed; all questions pass through.', {
|
||||
rawHead: raw.slice(0, 100),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
// LLM 출력을 원래 질문 목록에 매핑 — 누락된 질문은 unanswered 로 채움.
|
||||
const byQuestion = new Map(parsed.map((a) => [a.question.trim(), a]));
|
||||
return input.evidence.map((e) => {
|
||||
const hit = byQuestion.get(e.question.trim());
|
||||
if (hit && hit.status === 'answered' && hit.answer.trim()) {
|
||||
return { question: e.question, answered: true, answer: hit.answer.trim() };
|
||||
}
|
||||
return { question: e.question, answered: false, answer: '' };
|
||||
});
|
||||
}
|
||||
|
||||
/** 4-stage 관용 파서 — intentAlignment 와 동일 패턴 (작은 모델의 펜스/머리말 대응). */
|
||||
export function _parseSelfAnswerJson(raw: string): Array<{
|
||||
question: string;
|
||||
status: 'answered' | 'unanswered';
|
||||
answer: string;
|
||||
}> | null {
|
||||
if (!raw || !raw.trim()) return null;
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||
for (const candidate of [stage1, _extractFirstBalancedObject(stage1)]) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
const obj = JSON.parse(candidate);
|
||||
const coerced = _coerceSelfAnswers(obj);
|
||||
if (coerced) return coerced;
|
||||
} catch { /* 다음 stage */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _coerceSelfAnswers(obj: unknown): ReturnType<typeof _parseSelfAnswerJson> {
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
const answers = (obj as Record<string, unknown>).answers;
|
||||
if (!Array.isArray(answers)) return null;
|
||||
const out: Array<{ question: string; status: 'answered' | 'unanswered'; answer: string }> = [];
|
||||
for (const a of answers) {
|
||||
if (!a || typeof a !== 'object') continue;
|
||||
const r = a as Record<string, unknown>;
|
||||
const question = typeof r.question === 'string' ? r.question.trim() : '';
|
||||
if (!question) continue;
|
||||
const status = r.status === 'answered' ? 'answered' : 'unanswered';
|
||||
const answer = typeof r.answer === 'string' ? r.answer.trim() : '';
|
||||
out.push({ question, status, answer });
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function _extractFirstBalancedObject(s: string): string | null {
|
||||
const start = s.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let i = start; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inString) {
|
||||
if (escape) escape = false;
|
||||
else if (ch === '\\') escape = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inString = true; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return s.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Phase 3: 사용자 답변의 두뇌 저장 (학습 루프) ────────────────────────────
|
||||
|
||||
/** 저장 대상 필터 — 사용자가 *직접* 답한 실질적 정보만. */
|
||||
const MIN_ANSWER_CHARS = 20;
|
||||
|
||||
/**
|
||||
* 사용자가 alignment 라운드에서 직접 답해준 Q/A 를 두뇌의 전용 폴더에 일반
|
||||
* 노트로 저장한다. 자가 조사 항목(SELF_RESEARCH_PREFIX)과 20자 미만의 짧은
|
||||
* 답은 제외 — 두뇌 오염 방지. 같은 날 같은 요청의 노트가 이미 있으면 skip.
|
||||
*
|
||||
* @returns 저장된 파일 경로, 저장할 것이 없거나 실패하면 null (throw 금지).
|
||||
*/
|
||||
export function saveAlignmentKnowledge(
|
||||
brainPath: string,
|
||||
input: { userPrompt: string; qaList: Array<{ q: string; a: string }> },
|
||||
): string | null {
|
||||
try {
|
||||
if (!brainPath || !fs.existsSync(brainPath)) return null;
|
||||
const userAnswered = input.qaList.filter(
|
||||
(qa) => !qa.a.startsWith(SELF_RESEARCH_PREFIX) && qa.a.trim().length >= MIN_ANSWER_CHARS,
|
||||
);
|
||||
if (userAnswered.length === 0) return null;
|
||||
|
||||
const date = new Date();
|
||||
const ymd = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const slug = _slugify(input.userPrompt, 30);
|
||||
const dir = path.join(brainPath, ALIGNMENT_KNOWLEDGE_DIR);
|
||||
const filePath = path.join(dir, `${ymd} ${slug}.md`);
|
||||
if (fs.existsSync(filePath)) return null; // 같은 turn 재진입/재실행 — 중복 저장 방지
|
||||
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const body: string[] = [];
|
||||
body.push(`# ${input.userPrompt.replace(/\s+/g, ' ').trim().slice(0, 50)}`);
|
||||
body.push('');
|
||||
body.push(`> 1인 기업 모드 Intent Alignment 에서 사용자가 직접 제공한 정보. (${ymd})`);
|
||||
body.push('');
|
||||
body.push('## 원본 요청');
|
||||
body.push(input.userPrompt.trim());
|
||||
body.push('');
|
||||
body.push('## 확인된 정보');
|
||||
for (const qa of userAnswered) {
|
||||
body.push(`### Q. ${qa.q.replace(/\n/g, ' ').trim()}`);
|
||||
body.push(qa.a.trim());
|
||||
body.push('');
|
||||
}
|
||||
fs.writeFileSync(filePath, body.join('\n').trimEnd() + '\n', 'utf8');
|
||||
logInfo('alignmentResearch: saved user-provided knowledge to brain.', { filePath });
|
||||
return filePath;
|
||||
} catch (e: any) {
|
||||
logError('alignmentResearch: knowledge save failed (non-fatal).', {
|
||||
error: e?.message ?? String(e),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 파일명 안전 slug — 한글 보존, 경로 위험 문자만 제거. */
|
||||
export function _slugify(text: string, maxChars: number): string {
|
||||
const cleaned = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}$!'@`+=[\]]/g, '')
|
||||
.slice(0, maxChars)
|
||||
.trim();
|
||||
return cleaned || 'alignment';
|
||||
}
|
||||
@@ -215,6 +215,30 @@ export interface DispatcherDeps {
|
||||
* 'off'였던 경우.
|
||||
*/
|
||||
requirementContract?: RequirementContract;
|
||||
/**
|
||||
* 현재 워크스페이스의 아키텍처 컨텍스트 (architecture.md 요약, 호출자가
|
||||
* 절단해 전달). 일반 챗은 이 컨텍스트를 자동 주입받지만 기업 모드는 빠져
|
||||
* 있던 공백 수정 — specialist 가 "이 프로젝트가 뭐냐"를 추측하지 않게.
|
||||
* contract 와 같은 4개 지점(planner/specialist/verifier/inspector)에 prepend.
|
||||
*/
|
||||
architectureContextBlock?: string;
|
||||
/**
|
||||
* 사용자 프롬프트에 포함된 URL 의 pre-fetch 결과 ([URL CONTENT] 블록).
|
||||
* 기업 모드에는 continuation loop 가 없어 LLM 주도 fetch 결과를 재분석할
|
||||
* 기회가 없으므로, dispatch 전에 호출자가 가져와 모든 에이전트에게 배포.
|
||||
*/
|
||||
webContextBlock?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* deps 의 자동 수집 컨텍스트(architecture + web)를 한 prefix 문자열로 합성.
|
||||
* 둘 다 없으면 빈 문자열 — 기존 동작과 100% 동일.
|
||||
*/
|
||||
function buildExtraContextPrefix(deps: DispatcherDeps): string {
|
||||
const blocks = [deps.architectureContextBlock, deps.webContextBlock]
|
||||
.map((b) => (b || '').trim())
|
||||
.filter(Boolean);
|
||||
return blocks.length > 0 ? blocks.join('\n\n') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,11 +377,13 @@ export async function runCompanyTurn(
|
||||
};
|
||||
} else {
|
||||
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
|
||||
const plannerExtraPrefix = buildExtraContextPrefix(deps);
|
||||
const plannerContract = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined;
|
||||
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, {
|
||||
model: ceoModel,
|
||||
contractBlock: deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined,
|
||||
contractBlock: [plannerExtraPrefix, plannerContract].filter(Boolean).join('\n\n') || undefined,
|
||||
signal: deps.signal,
|
||||
});
|
||||
plan = plannerResult.plan;
|
||||
@@ -666,11 +692,13 @@ async function _dispatchOne(
|
||||
peerOutputs,
|
||||
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
|
||||
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
|
||||
// alignment 단계에서 도출된 contract가 deps에 있으면 모든 specialist의
|
||||
// system 프롬프트에 같은 ground truth로 prepend된다. 추측 방지.
|
||||
contractBlock: deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined,
|
||||
// alignment 단계에서 도출된 contract + 자동 수집 컨텍스트(architecture/web)가
|
||||
// deps에 있으면 모든 specialist의 system 프롬프트에 같은 ground truth로
|
||||
// prepend된다. 추측 방지.
|
||||
contractBlock: [
|
||||
buildExtraContextPrefix(deps),
|
||||
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
|
||||
].filter(Boolean).join('\n\n') || undefined,
|
||||
});
|
||||
// 우선순위: stage > agent > global default.
|
||||
const model = (stageModelOverride && stageModelOverride.trim())
|
||||
@@ -696,9 +724,10 @@ async function _dispatchOne(
|
||||
// 옛 dynamic import 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음.
|
||||
const cfgRuntime = getDispatcherConfig();
|
||||
if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
|
||||
const contractBlock = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract)
|
||||
: undefined;
|
||||
const contractBlock = [
|
||||
buildExtraContextPrefix(deps),
|
||||
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
|
||||
].filter(Boolean).join('\n\n') || undefined;
|
||||
const verdict = await verifyResponse(deps.ai, {
|
||||
task,
|
||||
response: rawResponse,
|
||||
@@ -1048,11 +1077,13 @@ async function _runReviewCycle(args: {
|
||||
return { verdict: 'aborted', rounds: round - 1 };
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
// contract가 있으면 검수자/CEO 모두에게 같은 ground truth를 prepend —
|
||||
// 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
|
||||
const contractPrefix = deps.requirementContract
|
||||
? formatContractForPrompt(deps.requirementContract) + '\n\n'
|
||||
: '';
|
||||
// contract + 자동 수집 컨텍스트가 있으면 검수자/CEO 모두에게 같은 ground
|
||||
// truth를 prepend — 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
|
||||
const contractPrefixParts = [
|
||||
buildExtraContextPrefix(deps),
|
||||
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
|
||||
].filter(Boolean).join('\n\n');
|
||||
const contractPrefix = contractPrefixParts ? contractPrefixParts + '\n\n' : '';
|
||||
|
||||
// ── 1) 검수자 LLM 콜 ──
|
||||
const inspectorSystem = contractPrefix + '당신은 산출물 *감리*입니다. 작업자의 결과물을 객관적으로 검토하고 한국어 마크다운으로 응답하세요.\n\n반드시 첫 줄을 다음 둘 중 하나로 시작:\n - ✅ 통과 — 산출물이 task 요구 + 위 contract의 criteria를 모두 충족하면.\n - ❌ 보완 필요: <구체 항목 한 줄> — contract 기준 누락·오류·약점이 있으면.\n\n그 다음 줄들에 *구체적인* 피드백 또는 칭찬 1~3줄. 모호한 일반론 금지.';
|
||||
|
||||
@@ -95,6 +95,13 @@ export type { ChatIntent, IntentContext, IntentResult, PipelineHint } from './in
|
||||
|
||||
export { analyzeIntent, formatContractForPrompt } from './intentAlignment';
|
||||
export type { IntentAnalysisInput, IntentAnalysisResult } from './intentAlignment';
|
||||
export {
|
||||
gatherEvidenceForQuestions,
|
||||
selfAnswerQuestions,
|
||||
saveAlignmentKnowledge,
|
||||
SELF_RESEARCH_PREFIX,
|
||||
} from './alignmentResearch';
|
||||
export type { QuestionEvidence, SelfAnswer } from './alignmentResearch';
|
||||
export type { RequirementContract } from './types';
|
||||
|
||||
export {
|
||||
|
||||
@@ -71,6 +71,14 @@ export interface IntentAnalysisInput {
|
||||
* 없으면 undefined (첫 진입 / 모드 토글 없는 케이스).
|
||||
*/
|
||||
priorChatSummary?: string;
|
||||
/**
|
||||
* 현재 열린 워크스페이스의 아키텍처 컨텍스트 (architecture.md 요약).
|
||||
* 사용자가 *지금 열어둔 프로젝트* 에 대해 요청하는 경우가 많은데, 분석기가
|
||||
* 이걸 못 보면 "그 프로젝트가 뭐냐"는 — 워크스페이스가 이미 답하고 있는 —
|
||||
* 질문을 던진다. 첫 라운드에만 첨부 (후속 라운드는 contract 에 흡수됨).
|
||||
* 작은 모델 보호를 위해 호출자가 3,000자 내외로 절단해 전달.
|
||||
*/
|
||||
projectContext?: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입니다. 사용자의 자연어 요청을 받아 그것을 실행 가능한 작업 조건 5가지(C-G-C-F-Q)로 정리합니다.
|
||||
@@ -85,6 +93,8 @@ const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입
|
||||
|
||||
⚠️ **[모드 전환 시 context 우선 추출]**: 입력에 \`[모드 전환 직전 일반 채팅 요약]\` 블록이 있으면, 그것을 **사용자의 한 줄과 같은 권위로** 취급하세요. 거기서 context/goal/criteria/format 을 *직접 추출* 한 뒤, 그래도 빠진 항목만 openQuestions 에 넣으세요. 사용자가 이미 일반 채팅에서 충분히 설명한 내용을 다시 물어보면 안 됩니다 — 일반 채팅에서 *명시적으로 언급* 된 항목은 추측이 아니라 **명시된 사실** 입니다.
|
||||
|
||||
⚠️ **[프로젝트 컨텍스트 우선 활용]**: 입력에 \`[프로젝트 컨텍스트]\` 블록이 있으면 그것은 *사용자가 지금 열어둔 워크스페이스* 의 실제 구조 요약입니다. 거기서 직접 확인되는 사실(이 프로젝트가 무엇인지, 기술 스택, 폴더 구조, 주요 기능)은 이미 알려진 정보로 취급해 context 슬롯에 반영하고, 그 블록에서 답이 확인되는 질문은 openQuestions 에 만들지 마세요. 예: 사용자가 그 프로젝트 이름·경로를 언급하며 요청하면 "그 프로젝트가 무엇인가요?" 같은 질문은 금지입니다.
|
||||
|
||||
confidence는 다음 기준으로 자체 판정:
|
||||
- "high" : C·G·C·F 4개 모두 prompt에서 직접 추론 가능. openQuestions = [] 가능.
|
||||
- "medium" : 대체로 명확하지만 1~2개 항목에서 합리적 가정 필요. 추가 질문 1~2개.
|
||||
@@ -119,6 +129,16 @@ function _buildUserMessage(input: IntentAnalysisInput): string {
|
||||
lines.push(input.priorChatSummary);
|
||||
lines.push('---');
|
||||
}
|
||||
// 현재 워크스페이스 아키텍처 요약 — "이 프로젝트가 뭐냐"류의 재질문 차단.
|
||||
if (input.projectContext && input.projectContext.trim()) {
|
||||
lines.push('');
|
||||
lines.push('[프로젝트 컨텍스트]');
|
||||
lines.push('아래는 사용자가 현재 열어둔 워크스페이스의 아키텍처 요약입니다. 여기서 직접');
|
||||
lines.push('확인되는 사실은 이미 알려진 정보로 취급해 context 슬롯에 반영하고, 다시 묻지 마세요.');
|
||||
lines.push('---');
|
||||
lines.push(input.projectContext);
|
||||
lines.push('---');
|
||||
}
|
||||
if (input.activePipelineName) {
|
||||
lines.push('');
|
||||
lines.push(`(활성 파이프라인) "${input.activePipelineName}"`);
|
||||
|
||||
@@ -140,6 +140,7 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
|
||||
parts.push(' • `<add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>` — 작업 추적기에 task 추가');
|
||||
parts.push(' • `<update_task id="t_001" status="in_progress|blocked|done" notes="..."/>` — 진척·blocker 갱신');
|
||||
parts.push(' • `<complete_task id="t_001"/>` — task 완료 처리 (done 섹션으로 이동)');
|
||||
parts.push(' • `<fetch_url url="https://..."/>` — 웹 페이지 본문 가져오기 (회당 최대 2개). "사이트 방문 불가"라고 답하지 말고 이 태그를 사용할 것. 결과는 [URL CONTENT] 블록으로 주입됨.');
|
||||
parts.push('');
|
||||
parts.push('📋 **Task 사용 시점**:');
|
||||
parts.push('- 회의록·요청 처리 중 *명확한 할일* 마다 add_task 1개씩 emit. 추측·확장 금지.');
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Web Fetch — Bridge 무관 직접 URL fetch (vscode 의존 없음 — 테스트 용이).
|
||||
*
|
||||
* 배경: 일반 챗의 URL 주입(urlContext.ts)은 Datacollect Bridge(:3002)에 100%
|
||||
* 의존했다. Bridge가 꺼져 있으면 — 확장은 Bridge를 자동 시작하지 않는다 —
|
||||
* "접근 실패" 블록이 떠서 모델이 "사이트 방문 불가"라고 답하는 공백이 있었다.
|
||||
* 이 모듈은 그 폴백: extension host의 global fetch(Node 18+, bridgeClient가
|
||||
* 이미 사용 중)로 직접 페이지를 가져와 본문 텍스트를 추출한다.
|
||||
*
|
||||
* 설계 원칙: 절대 throw 하지 않는다 — 모든 실패는 {ok:false, error} 로 반환.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 스킴 없는 도메인 인식용 보수적 TLD 목록 — 사용자는 "koritips.com 가서 분석해줘"
|
||||
* 처럼 https:// 를 생략하기 마련이라, 흔한 TLD 만 허용해 파일명(utils.ts,
|
||||
* package.json 등) 오인을 차단한다.
|
||||
*/
|
||||
const BARE_DOMAIN_TLDS = 'com|net|org|io|co|kr|jp|dev|app|ai|me|info|blog|shop|site|xyz|cc|tv|us|uk|edu|gov';
|
||||
|
||||
/**
|
||||
* http(s) URL 추출 — dedupe + trailing 구두점 제거. 슬래시 명령은 자체 처리하므로
|
||||
* 제외. https:// 가 없는 bare 도메인(koritips.com, www.foo.net 등)도 인식해
|
||||
* https:// 를 붙여 반환한다.
|
||||
*/
|
||||
export function extractUrls(text: string, max = 2): string[] {
|
||||
const t = (text || '').trim();
|
||||
// 슬래시 *명령* (/wikify 등)만 제외 — 절대경로("/Volumes/... koritips.com 봐줘")로
|
||||
// 시작하는 프롬프트는 URL 추출 대상이다 (startsWith('/') 는 경로를 오인했던 버그).
|
||||
if (!t || /^\/[a-zA-Z][\w-]*(\s|$)/.test(t)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
// ① 스킴 있는 URL 우선.
|
||||
const re = /https?:\/\/[^\s<>"'`)\]]+/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(t)) !== null && out.length < max) {
|
||||
// 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거.
|
||||
const url = m[0].replace(/[.,;:!?…」』)]+$/, '');
|
||||
if (!seen.has(url)) {
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
}
|
||||
if (out.length >= max) return out;
|
||||
|
||||
// ② Bare 도메인 — 이미 찾은 URL 영역은 마스킹해 이중 매칭 방지.
|
||||
// 직전 문자가 @(이메일)·/(경로 일부)·.(서브파트) 면 제외.
|
||||
let masked = t;
|
||||
for (const u of out) masked = masked.split(u).join(' '.repeat(Math.min(u.length, 8)));
|
||||
const bareRe = new RegExp(
|
||||
`(^|[\\s("'\`「『<>])((?:[a-z0-9-]+\\.)+(?:${BARE_DOMAIN_TLDS})(?:\\.[a-z]{2})?(?::\\d{2,5})?(?:/[^\\s<>"'\`)\\]]*)?)`,
|
||||
'gi',
|
||||
);
|
||||
while ((m = bareRe.exec(masked)) !== null && out.length < max) {
|
||||
const candidate = m[2].replace(/[.,;:!?…」』)]+$/, '');
|
||||
if (!candidate.includes('.')) continue;
|
||||
const url = `https://${candidate}`;
|
||||
if (!seen.has(url) && !seen.has(`http://${candidate}`)) {
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface WebFetchResult {
|
||||
ok: boolean;
|
||||
url: string;
|
||||
title: string;
|
||||
text: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_MAX_CHARS = 20_000;
|
||||
|
||||
/**
|
||||
* URL 본문을 직접 fetch 해 텍스트로 변환. HTML 이면 태그를 걷어내고,
|
||||
* 그 외(text/json 등)는 원문 그대로 cap. http/https 만 허용.
|
||||
*/
|
||||
export async function fetchUrlDirect(
|
||||
url: string,
|
||||
opts: { timeoutMs?: number; maxChars?: number } = {},
|
||||
): Promise<WebFetchResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
|
||||
const fail = (error: string): WebFetchResult => ({ ok: false, url, title: '', text: '', error });
|
||||
|
||||
if (!/^https?:\/\//i.test(url)) return fail('http/https URL만 지원합니다.');
|
||||
if (typeof fetch !== 'function') return fail('이 환경은 직접 fetch를 지원하지 않습니다.');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
// 일부 사이트가 UA 없는 요청을 차단 — 평범한 브라우저 UA 로.
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5',
|
||||
},
|
||||
});
|
||||
if (!res.ok) return fail(`HTTP ${res.status} ${res.statusText || ''}`.trim());
|
||||
const contentType = (res.headers.get('content-type') || '').toLowerCase();
|
||||
const raw = await res.text();
|
||||
if (!raw.trim()) return fail('응답 본문이 비어 있습니다.');
|
||||
|
||||
if (contentType.includes('html') || /^\s*<(!doctype|html)/i.test(raw)) {
|
||||
const title = _extractTitle(raw);
|
||||
const text = htmlToText(raw).slice(0, maxChars);
|
||||
if (text.trim().length < 50) {
|
||||
return fail('본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).');
|
||||
}
|
||||
return { ok: true, url, title, text };
|
||||
}
|
||||
return { ok: true, url, title: '', text: raw.slice(0, maxChars) };
|
||||
} catch (e: any) {
|
||||
const msg = e?.name === 'AbortError'
|
||||
? `타임아웃 (${Math.round(timeoutMs / 1000)}s)`
|
||||
: String(e?.message ?? e).slice(0, 120);
|
||||
return fail(msg);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function _extractTitle(html: string): string {
|
||||
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
return m ? decodeEntities(m[1]).replace(/\s+/g, ' ').trim().slice(0, 200) : '';
|
||||
}
|
||||
|
||||
/** HTML → 평문. script/style/noscript 제거 → 블록 태그를 줄바꿈으로 → 태그 strip → 엔티티 → 공백 정리. */
|
||||
export function htmlToText(html: string): string {
|
||||
let s = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ');
|
||||
// 블록 요소 경계를 줄바꿈으로 보존 — 문단 구조가 텍스트에도 남게.
|
||||
s = s.replace(/<\/(p|div|section|article|li|tr|h[1-6]|blockquote|pre)>/gi, '\n')
|
||||
.replace(/<(br|hr)\s*\/?>/gi, '\n');
|
||||
s = s.replace(/<[^>]+>/g, ' ');
|
||||
s = decodeEntities(s);
|
||||
// 공백 정리: 줄 내 다중 공백 → 1개, 3개 이상 연속 줄바꿈 → 2개.
|
||||
return s
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 최소 엔티티 디코드 — 본문 가독에 필요한 흔한 것만. */
|
||||
export function decodeEntities(s: string): string {
|
||||
return s
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/�?39;|'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_, code) => {
|
||||
const n = Number(code);
|
||||
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : '';
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user