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:
@@ -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. 원본 사이트 재구축 명세');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 확정', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('섹션 데이터가 스캔에 없습니다');
|
||||
});
|
||||
});
|
||||
@@ -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('답변 끝 <');
|
||||
});
|
||||
});
|
||||
@@ -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('표면 질문만 문자적으로 답하지 마라');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user