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:
2026-07-11 21:02:15 +09:00
parent 98d533f045
commit 47b3b9f93a
66 changed files with 2421 additions and 459 deletions
+77
View File
@@ -0,0 +1,77 @@
import { buildSynthesisPrompt } from '../src/features/datacollect/prompts/synthesisPrompt';
// handlers.ts 는 vscode·bridge 의존이 커서 순수 함수만 골라 검증한다
// (vscode 는 전역 moduleNameMapper mock 이 처리 — 로컬 jest.mock 금지).
import { parseBenchmarkArgs, BENCHMARK_MODE_PARTS } from '../src/features/datacollect/handlers';
const SCAN = {
url: 'https://interiorai.com',
meta: { title: 'InteriorAI', description: 'Fire your interior designer', lang: 'en' },
design: { colors: { palette: [{ value: 'rgb(10,10,10)', count: 40 }] }, typography: { primaryFont: 'Inter' } },
microcopy: { headline: 'Fire your interior designer', ctaSamples: ['Get started $29/mo'] },
structure: { h1: 'Fire your interior designer' },
sitemap: { totalPages: 3, crawlDepth: 1, ascii: '/', pages: [] },
};
describe('parseBenchmarkArgs — 모드 파싱', () => {
test('기본 모드는 full (디자인+비즈니스)', () => {
const r = parseBenchmarkArgs('https://a.com depth=2 pages=10 부동산 쪽 변형');
expect(r).toMatchObject({ url: 'https://a.com', depth: 2, pages: 10, mode: 'full', userContent: '부동산 쪽 변형' });
});
test('bare 키워드와 mode= 표기를 모두 받는다', () => {
expect(parseBenchmarkArgs('https://a.com build').mode).toBe('build');
expect(parseBenchmarkArgs('https://a.com mode=biz').mode).toBe('biz');
expect(parseBenchmarkArgs('https://a.com mode:design').mode).toBe('design');
});
test('모드 키워드는 보조 설명에 섞이지 않는다', () => {
const r = parseBenchmarkArgs('https://a.com biz 인테리어 시장 분석');
expect(r.mode).toBe('biz');
expect(r.userContent).toBe('인테리어 시장 분석');
});
test('URL 없으면 url 빈 문자열', () => {
expect(parseBenchmarkArgs('design').url).toBe('');
});
});
describe('BENCHMARK_MODE_PARTS — 모드별 파트 구성', () => {
test('full=1~4, design=1~3, biz=4만, build=1~5', () => {
expect(BENCHMARK_MODE_PARTS.full).toEqual([1, 2, 3, 4]);
expect(BENCHMARK_MODE_PARTS.design).toEqual([1, 2, 3]);
expect(BENCHMARK_MODE_PARTS.biz).toEqual([4]);
expect(BENCHMARK_MODE_PARTS.build).toEqual([1, 2, 3, 4, 5]);
});
});
describe('buildSynthesisPrompt — 파트 4·5 템플릿', () => {
test('파트 4는 비즈니스 렌즈 섹션(포지셔닝·가격·MVP·운영)을 요구한다', () => {
const p = buildSynthesisPrompt(SCAN, '', 4);
expect(p).toContain('10. 서비스 정의 & 포지셔닝');
expect(p).toContain('11. 가격 & 수익 구조');
expect(p).toContain('12. 기능 인벤토리');
expect(p).toContain('13. 운영 & 방향성 제안');
expect(p).toContain('MVP 컷');
expect(p).toContain('손익분기');
// 날조 금지 — 스캔에 없는 가격을 지어내지 않도록.
expect(p).toContain('스캔 데이터 부족');
});
test('파트 4에서만 사용자 컨텍스트 결합 변형을 지시한다', () => {
const p4 = buildSynthesisPrompt(SCAN, '부동산 중개용으로 변형', 4);
expect(p4).toContain('사용자 컨텍스트 결합 변형');
expect(p4).toContain('부동산 중개용으로 변형');
const p3 = buildSynthesisPrompt(SCAN, '부동산 중개용으로 변형', 3);
expect(p3).toContain('재해석·확장·전환하지 말 것'); // 원본 복원 원칙은 그대로
});
test('파트 5는 자기완결적 스텝 프롬프트와 실측 토큰 명시를 요구한다', () => {
const p = buildSynthesisPrompt(SCAN, '', 5);
expect(p).toContain('단계별 빌드 프롬프트');
expect(p).toContain('STEP 1');
expect(p).toContain('STEP 4');
expect(p).toContain('자기완결적');
expect(p).toContain('빌드 순서 요약');
});
test('파트 1~3 기존 템플릿은 변하지 않았다', () => {
expect(buildSynthesisPrompt(SCAN, '', 1)).toContain('1. 시각적 정체성');
expect(buildSynthesisPrompt(SCAN, '', 2)).toContain('8. 페이지 템플릿 맵');
expect(buildSynthesisPrompt(SCAN, '', 3)).toContain('9. 원본 사이트 재구축 명세');
});
});
+73
View File
@@ -0,0 +1,73 @@
import { flattenClaudePrompt, buildClaudeCliArgs, extractFromStreamJson, listClaudeCodeModels } from '../src/features/providers/claudeCode';
import { parseModelPrefix, makeModelId } from '../src/features/providers/types';
describe('claude: 프리픽스 라우팅', () => {
test('claude:sonnet → claude-code 프로바이더로 라우팅', () => {
expect(parseModelPrefix('claude:sonnet')).toEqual({ provider: 'claude-code', model: 'sonnet' });
});
test('makeModelId 는 프리픽스 테이블을 따른다 (claude-code → claude:)', () => {
expect(makeModelId('claude-code', 'sonnet')).toBe('claude:sonnet');
expect(makeModelId('anthropic', 'claude-opus-4-8')).toBe('anthropic:claude-opus-4-8');
});
test('로컬 모델은 여전히 null (로컬 엔진 경로)', () => {
expect(parseModelPrefix('gemma4:e2b')).toBeNull();
});
});
describe('flattenClaudePrompt — 메시지 평탄화', () => {
test('시스템 블록·화자 라벨·도구 금지 지시를 포함한다', () => {
const p = flattenClaudePrompt([
{ role: 'system', content: '너는 아스트라다.' },
{ role: 'user', content: '안녕' },
{ role: 'assistant', content: '반갑습니다' },
{ role: 'user', content: '이어서 질문' },
]);
expect(p).toContain('<system_instructions>\n너는 아스트라다.\n</system_instructions>');
expect(p).toContain('도구를 사용하지 말고');
expect(p).toContain('[사용자]\n안녕');
expect(p).toContain('[아스트라]\n반갑습니다');
expect(p.indexOf('[사용자]\n안녕')).toBeLessThan(p.indexOf('[사용자]\n이어서 질문'));
});
test('시스템 없는 단일 user 도 동작', () => {
expect(flattenClaudePrompt([{ role: 'user', content: 'hi' }])).toBe('[사용자]\nhi');
});
});
describe('buildClaudeCliArgs', () => {
test('headless stream-json + 모델 지정', () => {
expect(buildClaudeCliArgs('sonnet')).toEqual(['-p', '--output-format', 'stream-json', '--verbose', '--model', 'sonnet']);
});
test('모델 비면 --model 생략 (CLI 기본 모델)', () => {
expect(buildClaudeCliArgs('')).toEqual(['-p', '--output-format', 'stream-json', '--verbose']);
});
});
describe('extractFromStreamJson — CLI 출력 파싱', () => {
test('assistant 이벤트의 text 블록을 이어붙인다', () => {
const lines = [
'{"type":"system","subtype":"init"}',
'{"type":"assistant","message":{"content":[{"type":"text","text":"안녕"},{"type":"text","text":"하세요"}]}}',
'{"type":"result","subtype":"success","result":"안녕하세요"}',
];
expect(extractFromStreamJson(lines)).toEqual({ text: '안녕하세요', error: '' });
});
test('assistant 이벤트가 없으면 result 본문을 사용 (버전 차이 대응)', () => {
const lines = ['{"type":"result","subtype":"success","result":"본문"}'];
expect(extractFromStreamJson(lines).text).toBe('본문');
});
test('에러 result 는 error 로 추출', () => {
const lines = ['{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Invalid API key"}'];
const r = extractFromStreamJson(lines);
expect(r.text).toBe('');
expect(r.error).toContain('Invalid API key');
});
test('JSON 아닌 라인·깨진 라인은 무시', () => {
expect(extractFromStreamJson(['잡음', '{broken', ''])).toEqual({ text: '', error: '' });
});
});
describe('listClaudeCodeModels', () => {
test('구독 별칭 3종', () => {
expect(listClaudeCodeModels()).toEqual(['sonnet', 'opus', 'haiku']);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@
*/
import { classifyKnowledgeDomain, combineDomainFolders, buildDomainRoutingHint } from '../src/retrieval/domainRouter';
jest.mock('vscode', () => ({ workspace: { getConfiguration: jest.fn() } }), { virtual: true });
// vscode 는 jest.config.js 의 moduleNameMapper(tests/mocks/vscode.js)가 전역 mock 으로 처리한다.
describe('classifyKnowledgeDomain', () => {
it('코드 펜스가 있으면 coding 확정', () => {
+85
View File
@@ -0,0 +1,85 @@
import { buildHandoffDoc } from '../src/features/datacollect/prompts/handoffDoc';
import { buildClaudeCommand } from '../src/features/claude/claudeLauncher';
import { parseBenchmarkArgs, BENCHMARK_MODE_PARTS } from '../src/features/datacollect/handlers';
const SCAN = {
meta: { title: 'InteriorAI', description: 'Fire your interior designer', lang: 'en' },
design: {
colors: {
palette: [{ value: 'rgb(10,10,10)', count: 40 }, { value: 'rgb(255,87,51)', count: 12 }],
background: 'rgb(255,255,255)', primaryText: 'rgb(10,10,10)',
buttonBackground: 'rgb(255,87,51)', buttonText: 'rgb(255,255,255)',
},
typography: { primaryFont: 'Inter', h1: { size: '48px', weight: 700 }, body: { size: '16px' } },
layout: { bodyMaxWidth: '1200px', sectionSpacing: '96px', borderRadiusScale: ['8px', '16px'] },
},
interactions: { hoverRules: ['button: background 0.2s ease'] },
microcopy: { headline: 'Fire your interior designer', ctaSamples: ['Get started $29/mo'] },
structure: { h1: 'Fire your interior designer' },
sitemap: {
totalPages: 2, crawlDepth: 1, ascii: '/\n└── /pricing',
pages: [
{ url: '/', role: 'landing', h1: 'Fire your interior designer', primaryContentType: 'hero', imageCount: 6, ctaSamples: ['Get started'] },
{ url: '/pricing', role: 'pricing', title: 'Pricing', primaryContentType: 'table', imageCount: 0, formFields: [{ name: 'email', type: 'email' }] },
],
},
};
describe('buildHandoffDoc — 측정치 무손실 인수인계 문서', () => {
const doc = buildHandoffDoc(SCAN, 'https://interiorai.com', { crawlDepth: 1, maxPages: 8, userContent: '' });
test('측정된 원시 값이 그대로 들어간다 (rgb·px·카피 원문)', () => {
expect(doc).toContain('rgb(255,87,51)(×12)');
expect(doc).toContain('1200px');
expect(doc).toContain('Fire your interior designer');
expect(doc).toContain('Get started $29/mo');
});
test('클라우드 모델용 지시문과 임의 대체 금지 규칙을 포함한다', () => {
expect(doc).toContain('클라우드 모델용 지시문');
expect(doc).toContain('측정치를 그대로 사용하라');
expect(doc).toContain('추측하지 말고');
});
test('요구사항 미입력 시 채울 체크리스트를 제공한다', () => {
expect(doc).toContain('- [ ] 서비스 이름');
expect(doc).toContain('- [ ] 뺄 기능');
});
test('사용자 변형 지시가 있으면 요구사항 우선 원칙과 함께 삽입된다', () => {
const d2 = buildHandoffDoc(SCAN, 'https://interiorai.com', { crawlDepth: 1, maxPages: 8, userContent: '부동산 중개용으로 변형' });
expect(d2).toContain('부동산 중개용으로 변형');
expect(d2).toContain('요구사항이 우선한다');
});
test('페이지 인벤토리 표와 원시 JSON 부록을 포함한다', () => {
expect(doc).toContain('| /pricing | pricing |');
expect(doc).toContain('원시 스캔 데이터 부록');
expect(doc).toContain('"primaryFont": "Inter"');
});
test('빈 스캔에도 문서가 깨지지 않는다', () => {
const d = buildHandoffDoc({}, 'https://x.com', { crawlDepth: 0, maxPages: 1, userContent: '' });
expect(d).toContain('(스캔 데이터 없음)');
});
});
describe('handoff 모드 파싱·구성', () => {
test('handoff 는 bare/mode= 모두 인식되고 합성 파트가 없다', () => {
expect(parseBenchmarkArgs('https://a.com handoff').mode).toBe('handoff');
expect(parseBenchmarkArgs('https://a.com mode=handoff').mode).toBe('handoff');
expect(BENCHMARK_MODE_PARTS.handoff).toEqual([]);
});
});
describe('buildClaudeCommand — 셸 안전 정규화', () => {
test('지시문을 큰따옴표 한 줄 인자로 만든다', () => {
expect(buildClaudeCommand('파일 읽고 개발해줘')).toBe('claude "파일 읽고 개발해줘"');
});
test('따옴표·POSIX 확장 문자를 무해화한다', () => {
expect(buildClaudeCommand('say "hi" and $HOME `ls`')).toBe(`claude "say 'hi' and HOME ls"`);
});
test('빈 지시문이면 대화형 실행', () => {
expect(buildClaudeCommand(' ')).toBe('claude');
});
});
+82
View File
@@ -0,0 +1,82 @@
import { buildLayoutSkeleton, classifyWidget } from '../src/features/datacollect/prompts/layoutSkeleton';
const SCAN = {
structure: {
sections: [
{ role: 'header', depth: 0, text: '메뉴', btns: 1, links: 8, imgs: 1, hint: 'site-header' },
{ role: 'section', depth: 1, text: '최신 글', btns: 0, links: 12, imgs: 10, hint: 'swiper-container hero-slider' },
{ role: 'section', depth: 1, text: '카테고리 글 목록', btns: 2, links: 20, imgs: 30, hint: 'post-grid archive' },
{ role: 'footer', depth: 0, text: '저작권', btns: 0, links: 4, imgs: 0, hint: 'site-footer' },
],
},
sitemap: {
pages: [
{ url: 'https://koritips.com/', role: 'other', primaryContentType: 'list-article', sectionRoles: [] },
{
url: 'https://koritips.com/about', role: 'about', primaryContentType: 'list-card',
sectionRoles: [
{ tag: 'header', hint: 'gnb', preview: '메뉴' },
{ tag: 'main', hint: 'about-content', preview: '소개 본문' },
{ tag: 'form', hint: 'newsletter-signup', preview: '구독하기' },
],
},
],
},
};
describe('classifyWidget — class/id 신호 위젯 판별', () => {
test('캐러셀 계열 class 를 롤링 배너로 감지', () => {
expect(classifyWidget('swiper-container hero-slider')).toContain('캐러셀');
expect(classifyWidget('slick-track')).toContain('캐러셀');
});
test('내비/푸터/구독 신호 판별', () => {
expect(classifyWidget('site-header gnb')).toContain('내비게이션');
expect(classifyWidget('newsletter-signup')).toContain('구독');
});
test('신호 없으면 null', () => {
expect(classifyWidget('random-xyz')).toBeNull();
expect(classifyWidget('')).toBeNull();
});
});
describe('buildLayoutSkeleton — 결정론 골격 리포트', () => {
const md = buildLayoutSkeleton(SCAN);
test('홈의 위→아래 블록 순서를 그린다 (헤더→캐러셀→그리드→푸터)', () => {
const iHeader = md.indexOf('내비게이션');
const iCarousel = md.indexOf('캐러셀');
const iGrid = md.indexOf('카드/리스트 그리드');
const iFooter = md.indexOf('푸터');
expect(iHeader).toBeGreaterThan(-1);
expect(iHeader).toBeLessThan(iCarousel);
expect(iCarousel).toBeLessThan(iGrid);
expect(iGrid).toBeLessThan(iFooter);
});
test('롤링 배너 유무에 즉답한다', () => {
expect(md).toContain('롤링 배너/캐러셀: **있음**');
});
test('하위 페이지 골격도 포함한다', () => {
expect(md).toContain('`/about`');
expect(md).toContain('구독/CTA');
});
test('버튼/이미지/링크 수를 표기한다', () => {
expect(md).toContain('이미지 10');
expect(md).toContain('링크 8');
});
test('캐러셀 신호가 없으면 "신호 없음 + 실사이트 확인 권장"으로 정직하게', () => {
const noCarousel = buildLayoutSkeleton({
structure: { sections: [{ role: 'header', hint: 'site-header', text: '' }] },
sitemap: { pages: [] },
});
expect(noCarousel).toContain('신호 없음');
expect(noCarousel).toContain('실사이트 확인 권장');
});
test('빈 스캔에도 깨지지 않는다', () => {
expect(buildLayoutSkeleton({})).toContain('섹션 데이터가 스캔에 없습니다');
});
});
+65
View File
@@ -0,0 +1,65 @@
import { LiveReasoningFilter } from '../src/lib/contextBuilders/liveReasoningFilter';
/** 토큰 배열을 순서대로 밀어넣고 화면에 방출된 텍스트를 합쳐 반환. */
function run(tokens: string[]): string {
const f = new LiveReasoningFilter();
let out = '';
for (const t of tokens) out += f.push(t);
out += f.flush();
return out;
}
describe('LiveReasoningFilter — 스트리밍 중 추론 구간 실시간 차단', () => {
test('일반 텍스트는 그대로 통과한다', () => {
expect(run(['안녕', '하세요. ', '무엇을 도와드릴까요?'])).toBe('안녕하세요. 무엇을 도와드릴까요?');
});
test('<think> 블록은 통째로 숨긴다', () => {
expect(run(['<think>사용자가 인사했다</think>', '반갑습니다.'])).toBe('반갑습니다.');
});
test('토큰 경계에 걸쳐 쪼개진 <think> 도 잡는다', () => {
expect(run(['<th', 'ink>내부 ', '추론</thi', 'nk>답', '변입니다'])).toBe('답변입니다');
});
test('<thinking> 과 <analysis> 변형도 처리한다', () => {
expect(run(['<thinking>...</thinking>', 'A'])).toBe('A');
expect(run(['<analysis>...</analysis>', 'B'])).toBe('B');
});
test('본문 중간의 think 블록도 앞뒤 본문을 보존한다', () => {
expect(run(['결론은 X입니다. ', '<think>검산</think>', '근거는 Y입니다.']))
.toBe('결론은 X입니다. 근거는 Y입니다.');
});
test('Harmony thought 채널은 숨기고 final 채널 본문만 통과한다', () => {
const tokens = [
'<|channel|>thought The user said "안녕". This is a greeting.',
'<|channel|>final<|message|>',
'반갑습니다. 무엇을 도와드릴까요?',
];
expect(run(tokens)).toBe('반갑습니다. 무엇을 도와드릴까요?');
});
test('채널 마커가 토큰 경계에 걸쳐도 잡는다', () => {
const tokens = ['<|chan', 'nel|>thought 내부 추론 텍스트 ', '<|channel|>fin', 'al<|mess', 'age|>답변'];
expect(run(tokens)).toBe('답변');
});
test('thought 채널이 <|end|> 로 끝나면 이후는 visible', () => {
expect(run(['<|channel|>thought 추론<|end|>', '본문'])).toBe('본문');
});
test('닫히지 않은 think 는 아무것도 방출하지 않는다 (최종 streamReplace 가 정리)', () => {
expect(run(['<think>끝나지 않는 추론...'])).toBe('');
});
test('마커가 아닌 < 문자는 보류 후 정상 방출된다', () => {
expect(run(['a < b 이고 ', 'c > d 입니다'])).toBe('a < b 이고 c > d 입니다');
expect(run(['제네릭 Array<string> 타입'])).toBe('제네릭 Array<string> 타입');
});
test('스트림이 잠재 마커 접두사로 끝나면 flush 로 회수된다', () => {
expect(run(['답변 끝 <'])).toBe('답변 끝 <');
});
});
+115
View File
@@ -0,0 +1,115 @@
import { measureReport, parseLooseJson, runReportQaLoop, REPORT_QA_RULES, buildReportQaItems, reportQaCriticalNums, formatIntentBrief } from '../src/agents/reportQa';
jest.mock('../src/core/services', () => ({
coreChat: jest.fn(),
}));
const { coreChat } = require('../src/core/services');
describe('measureReport — 결정적 실측치', () => {
test('본문 글자 수는 마크다운 문법을 제외하고 센다', () => {
const m = measureReport('## 제목\n\n본문 다섯글자\n\n```js\ncode();\n```');
expect(m.contentChars).toBe('제목 본문 다섯글자'.length);
expect(m.sectionCount).toBe(1);
});
test('출처 인용 흔적과 나열 비율을 측정한다', () => {
const text = [
'## 발견', '결론입니다 (출처: 문서A)', '「위키 문서B」에 따르면 그렇다.',
'- 항목 1', '- 항목 2', '일반 지식 기반의 추정이다.',
].join('\n');
const m = measureReport(text);
expect(m.citationCount).toBeGreaterThanOrEqual(3);
expect(m.listLineRatio).toBeGreaterThan(0);
});
});
describe('parseLooseJson — 관대한 JSON 파싱', () => {
test('코드펜스에 싸인 JSON 을 파싱한다', () => {
expect(parseLooseJson('```json\n{"score": 85, "feedback": "좋음"}\n```')).toEqual({ score: 85, feedback: '좋음' });
});
test('앞뒤 잡담이 섞여도 첫 { ~ 마지막 } 로 복구한다', () => {
expect(parseLooseJson('알겠습니다. {"score": 70} 입니다.')).toEqual({ score: 70 });
});
test('복구 불가면 null', () => {
expect(parseLooseJson('점수는 85점입니다')).toBeNull();
});
});
describe('QA 규칙 레지스트리', () => {
test('번호가 고유하고 채점 항목 문자열이 전 규칙을 포함한다', () => {
const nums = REPORT_QA_RULES.map(r => r.num);
expect(new Set(nums).size).toBe(nums.length);
const items = buildReportQaItems();
for (const r of REPORT_QA_RULES) expect(items).toContain(`(${r.num})`);
});
test('중대 결함 번호 목록은 critical 규칙에서 자동 생성된다', () => {
const criticals = REPORT_QA_RULES.filter(r => r.critical).map(r => r.num);
expect(reportQaCriticalNums()).toBe(criticals.join('·'));
});
});
describe('runReportQaLoop — 채점·재작성·회귀 게이트', () => {
beforeEach(() => (coreChat as jest.Mock).mockReset());
const brief = { purpose: 'p', realQuestions: ['q'], expectedUse: 'u' };
const longDraft = '초안 본문 '.repeat(50);
test('임계 이상이면 재작성 없이 그대로 통과', async () => {
(coreChat as jest.Mock).mockResolvedValueOnce({ content: '{"score": 88, "feedback": "통과"}', empty: false });
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: brief });
expect(r.finalDraft).toBe(longDraft);
expect(r.score).toBe(88);
expect(r.revisions).toBe(0);
});
test('임계 미달 → 재작성 → 점수 상승 시 채택', async () => {
const revised = '개선된 보고서 본문 '.repeat(40);
(coreChat as jest.Mock)
.mockResolvedValueOnce({ content: '{"score": 70, "feedback": "3번 근거 부족"}', empty: false }) // 초안 채점
.mockResolvedValueOnce({ content: revised, empty: false }) // 재작성
.mockResolvedValueOnce({ content: '{"score": 86, "feedback": "통과"}', empty: false }); // 재채점
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: brief });
expect(r.finalDraft).toBe(revised.trim());
expect(r.score).toBe(86);
expect(r.revisions).toBe(1);
expect(r.trajectory).toEqual([70, 86]);
});
test('[회귀 게이트] 재작성본 점수가 떨어지면 폐기하고 최고 초안 유지', async () => {
const worse = '나빠진 보고서 본문 '.repeat(40);
(coreChat as jest.Mock)
.mockResolvedValueOnce({ content: '{"score": 75, "feedback": "1번 미흡"}', empty: false })
.mockResolvedValueOnce({ content: worse, empty: false })
.mockResolvedValueOnce({ content: '{"score": 68, "feedback": "더 나빠짐"}', empty: false })
.mockResolvedValueOnce({ content: worse, empty: false })
.mockResolvedValueOnce({ content: '{"score": 60, "feedback": "악화"}', empty: false });
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: brief });
expect(r.finalDraft).toBe(longDraft); // 원본 유지
expect(r.score).toBe(75);
expect(r.trajectory).toEqual([75, 68, 60]);
});
test('채점 자체가 실패(JSON 파싱 불가)하면 초안을 그대로 반환한다', async () => {
(coreChat as jest.Mock).mockResolvedValueOnce({ content: '점수를 매길 수 없습니다', empty: false });
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: null });
expect(r.finalDraft).toBe(longDraft);
expect(r.score).toBeNull();
});
test('재작성본이 과도하게 짧으면(잘림) 회귀로 간주해 폐기한다', async () => {
(coreChat as jest.Mock)
.mockResolvedValueOnce({ content: '{"score": 60, "feedback": "부족"}', empty: false })
.mockResolvedValueOnce({ content: '짧음', empty: false }) // 심하게 잘린 재작성
.mockResolvedValueOnce({ content: '짧음', empty: false })
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: brief });
expect(r.finalDraft).toBe(longDraft);
});
});
describe('formatIntentBrief', () => {
test('브리핑을 성공 기준 지시와 함께 포맷한다', () => {
const out = formatIntentBrief({ purpose: '시장 진입 판단', realQuestions: ['경쟁 강도', '규제'], expectedUse: '투자 결정' });
expect(out).toContain('시장 진입 판단');
expect(out).toContain('경쟁 강도 / 규제');
expect(out).toContain('표면 질문만 문자적으로 답하지 마라');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { buildToneGuardBlock, AI_CLICHE_PATTERNS } from '../src/lib/contextBuilders/toneGuard';
import { getSystemPrompt } from '../src/utils';
describe('Tone Guard — AI 상투어 금지', () => {
const block = buildToneGuardBlock();
test('실사례 클리셰(상담사 화법·대기 선언·허락 구걸)를 전부 금지한다', () => {
expect(block).toContain('계시군요');
expect(block).toContain('저는 언제든 여기에 있습니다');
expect(block).toContain('괜찮으시다면');
expect(block).toContain('무엇을 도와드릴까요');
});
test('레지스트리의 모든 패턴이 블록에 나열된다 (단일 권위)', () => {
for (const p of AI_CLICHE_PATTERNS) expect(block).toContain(p);
});
test('대조 예시(나쁜 답 vs 좋은 답)와 동료 톤 지침을 포함한다', () => {
expect(block).toContain('나쁜 답');
expect(block).toContain('좋은 답');
expect(block).toContain('오버해서 친한 척도 하지 마라');
});
test('기본 페르소나(작업 턴)에도 주입된다', () => {
expect(getSystemPrompt()).toContain('[말투 — AI 상투어 금지]');
});
});
+14 -3
View File
@@ -42,9 +42,20 @@ describe('pickWikiDir — 저장 폴더 우선순위', () => {
expect(r!.dir).toBe(path.join('E:/ws', '.astra', 'wiki'));
});
test('상대경로 설정은 무시하고 다음 후보로 (경로 오염 방지)', () => {
const r = pickWikiDir('상대/경로', 'E:/brain', null);
expect(r!.label).toBe('두뇌');
test('[v2.2.304] 상대경로 설정은 두뇌 루트 기준으로 해석 (이식성)', () => {
const r = pickWikiDir('상대/경로', '/brain', null);
expect(r!.label).toBe('설정 폴더(두뇌 기준)');
expect(r!.dir.replace(/\\/g, '/')).toContain('/brain/상대/경로');
});
test('[v2.2.304] ../ 상대경로로 두뇌 밖 형제 폴더도 지정 가능', () => {
const r = pickWikiDir('../../Premium/자료', '/Wiki/10_Wiki/Topics', null);
expect(r!.dir.replace(/\\/g, '/')).toBe('/Wiki/Premium/자료');
});
test('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {
const r = pickWikiDir('상대/경로', '', '/ws');
expect(r!.label).toBe('워크스페이스');
});
test('④ 전부 없으면 null', () => {