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:
g1nation
2026-06-12 23:46:07 +09:00
parent 553aa0b134
commit a114d968b0
42 changed files with 4178 additions and 2088 deletions
+8 -3
View File
@@ -387,8 +387,6 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
excerpt: (c.content || '').slice(0, 200),
}));
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
// 살아남게.
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
const memoryBlock = deps.retrievalOrchestrator.buildContextString(result);
// [확신도 전역화] 검색 근거 강도를 평가해 답변 정책을 함께 주입 — /meet 의
@@ -409,7 +407,14 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
return [selfReviewBlock, groundingBlock, lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
// 옛 구현은 이 블록들을 RAG 본문과 같은 문자열에 합쳐 [CONTEXT] 안에 넣었는데,
// "실패 방지 제약"이 토큰 압박에서 배경 지식과 함께 잘려나가는 모순이 있었다.
const constraintBlock = [selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
return memoryBlock;
}
/**
@@ -83,3 +83,25 @@ export function isCasualConversationPrompt(prompt: string): boolean {
if (/^(?:ha){2,}h?$|^(?:he){2,}h?$/.test(normalized)) return true; // haha, hahaha, hehe
return false;
}
/**
* 슬래시 명령(/wikify, /benchmark 등)인지 — *명령만* 골라낸다. 절대경로
* ("/Volumes/Data/... 분석해줘")는 명령이 아니다. 옛 `startsWith('/')` 가드가
* 경로로 시작하는 프롬프트를 전부 명령으로 오인해 분석 지시·URL 주입이 통째로
* 죽는 버그가 있었다 (이 사용자 패턴의 대표형이 "경로 + 분석해줘").
* 패턴: 슬래시 + 명령어 + (공백|끝). 경로는 두 번째 '/'가 바로 이어져 미스매치.
*/
export function isSlashCommand(text: string): boolean {
return /^\/[a-zA-Z][\w-]*(\s|$)/.test((text || '').trim());
}
/**
* 분석/검토/의견형 요청 감지 — 이런 요청에서 모델이 코드를 읽지 않고 "~로
* 보입니다" 추측으로 답하는 실패 모드를 막기 위한 grounding 지시 주입 트리거.
* 보수적 휴리스틱: 분석 동사 포함 + 슬래시 명령 아님 + 최소 길이.
*/
export function isAnalysisRequest(prompt: string): boolean {
const p = (prompt || '').trim();
if (p.length < 8 || isSlashCommand(p)) return false;
return /(분석|검토|리뷰|평가|점검|진단|의견|개선점|개선 ?방향|review|analyze|audit|assess)/i.test(p);
}
+26 -4
View File
@@ -15,21 +15,38 @@ import * as fs from 'fs';
import * as path from 'path';
import { INVENTORY_FILE } from '../../extension/featureInventory';
const IMPROVE_RE = /(개선|고도화|발전|보완|제안|평가|분석|방향|방법|아이디어|로드맵|업그레이드|날카롭|강화)/i;
// "검토|리뷰|점검|진단|어때" 추가 — "아래 내용 검토해줘" 같은 실사용 재검토
// 프롬프트가 매치 안 되던 갭 (회귀 테스트로 발견).
// "학습|배우|기억|작동|어떻게" 추가 — "너는 어떻게 학습해?" 류 자기 작동 방식
// 질문이 인벤토리(학습 메커니즘 정본 포함) 없이 답변되어 허구 설명이 나오던 갭.
const IMPROVE_RE = /(개선|고도화|발전|보완|제안|평가|분석|검토|리뷰|점검|진단|어때|방향|방법|아이디어|로드맵|업그레이드|날카롭|강화|학습|배우|기억|작동|어떻게)/i;
const SELF_RE = /(기능|역량|능력|아키텍처|구조|시스템|self.?evolv|자기\s*진화|자기\s*개선|아스트라|astra|connectai|프로젝트|업무\s*능력|너(가|의|는)?|네가)/i;
const CAPABILITY_RE = /(무슨|어떤|할\s*수\s*있는)\s*(기능|일|것)|기능\s*(목록|리스트)|capabilit/i;
/**
* 자기 평가·개선·기능 질의인지. 오탐 비용이 낮으므로(인벤토리 ~3KB 추가 주입뿐)
* 누락(또 구식 제안)보다 과잉 감지를 택한다. 길이 상한 1500 — 사용자의 개선 요청은
* 배경 설명이 붙어 길어지는 경우가 많다 (600 으로는 실사용 질문을 놓침).
* 누락(또 구식 제안)보다 과잉 감지를 택한다. 길이 상한 없음 — 사용자가 이전 답변
* 전문(5천자+)을 붙여넣고 "이거 어때?"라고 묻는 재검토 패턴이 흔한데, 1500/4000
* 상한이 그런 실사용 프롬프트를 탈락시켜 인벤토리 미주입 → 기존 기능 재제안
* 버그가 반복 재발했다. 정규식 검사는 큰 문자열에도 저비용.
*/
export function isSelfAssessRequest(prompt: string): boolean {
const p = (prompt || '').trim();
if (!p || p.length > 1500) return false;
if (!p) return false;
return CAPABILITY_RE.test(p) || (IMPROVE_RE.test(p) && SELF_RE.test(p));
}
/**
* 분석/검토 대상이 ASTRA/ConnectAI *자신*인지 — isSelfAssessRequest 보다 넓은
* 보조 판정 (개선 키워드 없이 "검토해줘/어때?" 만으로 자기 분석을 요청하는 경우).
* 분석 경로(isAnalysisRequest)와 결합해 인벤토리 주입 트리거를 보강한다.
*/
export function isAboutSelf(prompt: string): boolean {
const p = (prompt || '').trim();
if (!p) return false;
return /(아스트라|astra|connectai|자기\s*진화|자기\s*개선|self.?evolv|기능\s*인벤토리)/i.test(p);
}
const MAX_INVENTORY_CHARS = 7000;
/** 인벤토리 전문 + 대조 지시 블록. 파일 없으면 정직한 안내 (지어내기 방지). */
@@ -54,6 +71,11 @@ export function buildSelfAssessContext(brainPath: string): string {
'- 제안은 "현재 X가 있고, 빠진 증분은 Y" 형태로.',
'- 아래에 없는 기능을 있다고 주장하지도 마라.',
'- 직전 대화에서 본인이 했던 제안 목록을 그대로 반복하지 마라 — 이 인벤토리가 그 제안들보다 우선하는 최신 사실이다.',
'⚠️ [의무 형식 — 위반 시 오답 처리] 개선 제안을 하나라도 포함하는 답변은, **각 제안의 첫 줄에** 아래 대조 태그를 반드시 붙여라:',
' `[인벤토리 대조: 신규]` — 아래 문서 어디에도 없는 기능',
' `[인벤토리 대조: 기구현 — <항목명>]` — 이미 있음 (이 경우 "도입" 대신 그 기능의 *증분 확장*만 제안 가능)',
' `[인벤토리 대조: 부분 구현 — <항목명>]` — 일부 겹침',
' 태그를 붙이려면 아래 문서를 실제로 읽어야 한다. 태그 없는 제안은 검증 안 된 추측으로 간주된다.',
'- 답변 끝에 "출처: ASTRA 기능 인벤토리 v<버전>" 을 표기하라 (이 블록을 실제로 읽었다는 증거).',
'',
body,
+70 -26
View File
@@ -4,16 +4,30 @@
* 문제: /wikify 는 URL 에 접근하지만(브리지 /api/web-extract), 일반 채팅에 URL 을
* 주면 추출 경로가 없어 모델이 "접근할 수 없습니다"라고 답하거나 내용을 추측했다.
*
* 수정: 강제 주입 패턴의 4번째 적용 (일정→캘린더, 자기평가→인벤토리, 정정→캡처와
* 동일 설계). 이미 검증된 브리지 추출 인프라를 재사용 — 새 크롤러 없음.
* 실패 시(브리지 다운/추출 실패) 정직한 안내 블록 — 모델이 내용을 지어내지 않게.
* v2: Bridge 100% 의존이 두 번째 공백이었다 — Bridge 가 꺼져 있으면(확장은 자동
* 시작하지 않음) 접근 실패 블록이 떠서 결국 "방문 불가"가 됐다. 이제 ① Bridge
* 추출(품질 우선) → ② 직접 fetch 폴백(webFetch.fetchUrlDirect) → ③ 정직 실패
* 블록의 3단계. 성공 결과는 5분 TTL 캐시 — chat/alignment/기업모드가 같은 URL 을
* 연달아 요청해도 네트워크 1회.
*/
import { logInfo } from '../../utils';
import { bridgeFetch, BRIDGE_API } from '../../features/datacollect/bridgeClient';
import { fetchUrlDirect } from '../../features/web/webFetch';
const URL_RE = /https?:\/\/[^\s<>"'`)\]]+/i;
const MAX_BODY_CHARS = 8000;
const EXTRACT_TIMEOUT_MS = 45_000;
// Bridge 추출 대기 — 직접 fetch 폴백이 있으므로 45s → 15s 로 단축 (총 대기 최대 ~30s).
const EXTRACT_TIMEOUT_MS = 15_000;
// 성공 블록만 캐시 (실패는 캐시하지 않음 — Bridge 재기동/일시 오류 후 재시도 가능해야).
const CACHE_TTL_MS = 5 * 60 * 1000;
const CACHE_MAX = 10;
const _cache = new Map<string, { block: string; expiresAt: number }>();
/** 테스트/세션 전환용 캐시 초기화. */
export function clearUrlContextCache(): void {
_cache.clear();
}
/**
* 프롬프트에서 추출 대상 URL 을 찾는다. 슬래시 명령(/wikify 등)은 자체 처리하므로
@@ -21,7 +35,8 @@ const EXTRACT_TIMEOUT_MS = 45_000;
*/
export function extractUrlFromPrompt(prompt: string): string | null {
const p = (prompt || '').trim();
if (!p || p.startsWith('/')) return null;
// 슬래시 *명령* 만 제외 — 절대경로 시작 프롬프트는 추출 대상 (경로 오인 버그 수정).
if (!p || /^\/[a-zA-Z][\w-]*(\s|$)/.test(p)) return null;
const m = URL_RE.exec(p);
if (!m) return null;
// 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거.
@@ -30,6 +45,12 @@ export function extractUrlFromPrompt(prompt: string): string | null {
/** URL 본문을 추출해 컨텍스트 블록 생성. 실패해도 throw 하지 않는다 (정직 블록 반환). */
export async function buildUrlContext(url: string): Promise<string> {
const now = Date.now();
const cached = _cache.get(url);
if (cached && cached.expiresAt > now) return cached.block;
// ① Bridge 추출 (readability 급 품질 — 우선).
let bridgeError = '';
try {
const data = await bridgeFetch<{ success: boolean; title?: string; description?: string; text?: string; textLength?: number; truncated?: boolean }>(
BRIDGE_API.web.extract,
@@ -37,28 +58,51 @@ export async function buildUrlContext(url: string): Promise<string> {
{ timeoutMs: EXTRACT_TIMEOUT_MS },
);
const body = String(data?.text || '').slice(0, MAX_BODY_CHARS);
if (!body.trim() || body.trim().length < 50) {
return [
`[URL CONTENT — ${url}]`,
'상태: 본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).',
'→ 사용자에게 이 URL 의 본문을 가져오지 못했다고 정직하게 알리고, 내용을 추측해 답하지 마라.',
].join('\n');
if (body.trim() && body.trim().length >= 50) {
logInfo('URL 컨텍스트 주입 (bridge).', { url, chars: body.length, truncated: !!data?.truncated });
const block = _successBlock(url, data?.title || '', data?.description || '', body, body.length >= MAX_BODY_CHARS || !!data?.truncated);
_cachePut(url, block);
return block;
}
logInfo('URL 컨텍스트 주입.', { url, chars: body.length, truncated: !!data?.truncated });
return [
`[URL CONTENT — 실데이터 · ${url}]`,
`제목: ${data?.title || '(없음)'}`,
data?.description ? `설명: ${data.description}` : '',
'아래 본문만 근거로 답하라. 본문에 없는 내용은 "본문에서 확인되지 않음"이라고 답하고 지어내지 마라.' +
(body.length >= MAX_BODY_CHARS || data?.truncated ? ' (본문 일부 잘림 — 전체가 필요하면 /wikify 사용을 안내하라.)' : ''),
'',
body,
].filter(Boolean).join('\n');
bridgeError = '본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링)';
} catch (e: any) {
return [
`[URL CONTENT — ${url}]`,
`상태: 접근 실패 — ${String(e?.message ?? e).slice(0, 120)}`,
'→ Datacollect 브리지(:3002)가 실행 중인지 확인하라고 사용자에게 안내하고, URL 내용을 추측해 답하지 마라.',
].join('\n');
bridgeError = String(e?.message ?? e).slice(0, 120);
}
// ② 직접 fetch 폴백 — Bridge 가 꺼져 있거나 추출 실패해도 웹 접근은 살아 있어야.
const direct = await fetchUrlDirect(url, { maxChars: MAX_BODY_CHARS });
if (direct.ok) {
logInfo('URL 컨텍스트 주입 (direct fetch 폴백).', { url, chars: direct.text.length, bridgeError });
const block = _successBlock(url, direct.title, '', direct.text, direct.text.length >= MAX_BODY_CHARS);
_cachePut(url, block);
return block;
}
// ③ 둘 다 실패 — 모델이 내용을 지어내지 않게 정직 블록.
return [
`[URL CONTENT — ${url}]`,
`상태: 접근 실패 — bridge: ${bridgeError || '(미시도)'} / direct: ${direct.error || '(불명)'}`,
'→ 사용자에게 이 URL 의 본문을 가져오지 못했다고 정직하게 알리고, 내용을 추측해 답하지 마라.',
].join('\n');
}
function _successBlock(url: string, title: string, description: string, body: string, truncated: boolean): string {
return [
`[URL CONTENT — 실데이터 · ${url}]`,
`제목: ${title || '(없음)'}`,
description ? `설명: ${description}` : '',
'아래 본문만 근거로 답하라. 본문에 없는 내용은 "본문에서 확인되지 않음"이라고 답하고 지어내지 마라.' +
(truncated ? ' (본문 일부 잘림 — 전체가 필요하면 /wikify 사용을 안내하라.)' : ''),
'',
body,
].filter(Boolean).join('\n');
}
function _cachePut(url: string, block: string): void {
if (_cache.size >= CACHE_MAX) {
// 가장 오래된 entry 제거 (Map 삽입 순서).
const oldest = _cache.keys().next().value;
if (oldest !== undefined) _cache.delete(oldest);
}
_cache.set(url, { block, expiresAt: Date.now() + CACHE_TTL_MS });
}