v2.2.313~319: 보고 품질·조사 신뢰성 수술 + 네이버 쇼핑인사이트/검색어트렌드 연동

- 파트별 완성, 웹 실접속 증빙, 턴 의도 브리핑, 본문 불일치 검증, 하위 질문
  체크리스트, 사이트 심층 수집 (PATCHNOTES.md v2.2.313~318 참조)
- /naver-shopping, /naver-trend: 네이버 데이터랩 API 연동. compare/discover
  자동 판별, API 배치 상한 캡슐화, 카테고리 트리 fuzzy 매칭, 자격증명은
  Astra 설정 패널 SecretStorage 로 관리
This commit is contained in:
2026-08-05 18:20:32 +09:00
parent 7d6b8b509f
commit c79eda1862
51 changed files with 37435 additions and 89 deletions
+24
View File
@@ -191,3 +191,27 @@ describe('detectUngroundedAnalysis — 일반론 분석 감지', () => {
expect(formatUngroundedAnalysisFooter(3)).toContain('3회');
});
});
// [v2.2.313] 액션 0회 사각지대 — 파일을 아예 안 읽고 낸 장문 분석.
import { detectUnreadAnalysis, formatUnreadAnalysisFooter } from '../src/intelligence/investigationPipeline';
describe('detectUnreadAnalysis — 파일 미확인 분석 감지', () => {
const long = '이 프로젝트는 다층 메모리 구조를 갖춘 고도화된 시스템입니다. '.repeat(10);
test('로컬 경로 컨텍스트 + 액션 0회 + 장문 = 감지', () => {
expect(detectUnreadAnalysis(long, { reads: 0, lists: 0, investigates: 0 }, true)).toBe(true);
});
test('액션을 1회라도 썼으면 대상 아님 (hollow/ungrounded 영역)', () => {
expect(detectUnreadAnalysis(long, { reads: 1, lists: 0, investigates: 0 }, true)).toBe(false);
expect(detectUnreadAnalysis(long, { reads: 0, lists: 1, investigates: 0 }, true)).toBe(false);
});
test('로컬 경로 컨텍스트가 없으면 대상 아님', () => {
expect(detectUnreadAnalysis(long, { reads: 0, lists: 0, investigates: 0 }, false)).toBe(false);
});
test('짧은 답변은 오탐 방지로 제외', () => {
expect(detectUnreadAnalysis('짧은 확인 답변.', { reads: 0, lists: 0, investigates: 0 }, true)).toBe(false);
});
test('footer 에 재요청 안내 포함', () => {
expect(formatUnreadAnalysisFooter()).toContain('파일 미확인 분석');
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* [v2.2.314] 두 실사례 가드:
* 1) LSEP 마커 뒤 "마무리 인사"만 남고 본문이 추론 구간에 갇혀 증발한 답변.
* 2) "요청 요약/사용자 의도 추론/핵심 확인 질문" 내부 스캐폴딩 섹션 유출.
*/
import {
splitLsepReasoning,
isCloserOnlyAnswer,
looksLikeReportBody,
stripLeakedInternalSections,
} from '../src/lib/contextBuilders/outputSanitization';
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_a1b2c3d4e5f6__';
describe('splitLsepReasoning / isCloserOnlyAnswer / looksLikeReportBody', () => {
test('마지막 END 마커 기준으로 before/after 분해', () => {
const body = '## 1. 구조\n분석 본문. '.repeat(50);
const r = splitLsepReasoning(`${body}${MARKER}이상으로 분석을 마칩니다.`);
expect(r.hasMarker).toBe(true);
expect(r.before).toContain('## 1. 구조');
expect(r.after).toBe('이상으로 분석을 마칩니다.');
});
test('마커 없으면 hasMarker=false, after=원문', () => {
const r = splitLsepReasoning('일반 답변');
expect(r.hasMarker).toBe(false);
expect(r.after).toBe('일반 답변');
});
test('마무리 인사만 = closer-only, 실질 본문은 아님', () => {
expect(isCloserOnlyAnswer('이상으로 ConnectAI 프로젝트에 대한 분석을 마칩니다. 추가 검토가 필요하시면 언제든 요청해 주세요.')).toBe(true);
expect(isCloserOnlyAnswer('## 구조 분석\n실제 본문이 여기 길게 이어진다.')).toBe(false);
});
test('헤딩 다수 장문 = 보고 본문, 짧은 텍스트 = 아님', () => {
const report = ['## 개요', '본문 문단. '.repeat(60), '## 구조', '본문 문단. '.repeat(60)].join('\n\n');
expect(looksLikeReportBody(report)).toBe(true);
expect(looksLikeReportBody('짧은 생각')).toBe(false);
});
});
describe('stripLeakedInternalSections — 내부 섹션 유출 제거', () => {
test('금지 4종 섹션과 "질문 의도:" 줄을 제거하고 본문·출처는 보존', () => {
const answer = [
'## 프로젝트 개요', '실제 분석 본문.',
'## 요청 요약', 'ConnectAI 프로젝트 분석 요청입니다.',
'## 사용자 의도 추론', '전체 그림을 이해하고자 하는 의도로 판단됩니다.',
'## 프로젝트 기록 대상 확인', '기록으로 관리될 예정입니다.',
'## 핵심 확인 질문', '심층 분석이 필요하신가요?',
'질문 의도: 범위 확장 여부 결정.',
'## 분석 결과 요약', '요약 본문.',
].join('\n');
const out = stripLeakedInternalSections(answer);
expect(out).toContain('## 프로젝트 개요');
expect(out).toContain('## 분석 결과 요약');
expect(out).not.toContain('요청 요약');
expect(out).not.toContain('의도 추론');
expect(out).not.toContain('기록 대상 확인');
expect(out).not.toContain('핵심 확인 질문');
expect(out).not.toContain('질문 의도');
});
test('금지 섹션이 없으면 원문 유지', () => {
const clean = '## 개요\n본문\n\n## 다음 단계\n제안';
expect(stripLeakedInternalSections(clean)).toBe(clean);
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* /naver-shopping, /naver-trend 순수 로직 테스트.
* 실제 네이버 API 호출은 통합 영역이라 제외 — 카테고리 매칭 + intent 파서(tolerant
* JSON 파싱, AI 호출 mock)만 고정.
*/
import { findCategoryCode, ROOT_CATEGORY_CODES } from '../src/features/naverTrend/categoryCodes';
import { parseShoppingIntent, parseSearchTrendIntent } from '../src/features/naverTrend/intentParser';
import type { IAIService, AIChatResult } from '../src/core/services';
function mockAi(content: string, shouldThrow = false): IAIService {
return {
call: async () => content,
chat: async (): Promise<AIChatResult> => {
if (shouldThrow) throw new Error('AI 호출 실패(mock)');
return { content, engine: 'lmstudio', model: 'mock', empty: false };
},
};
}
describe('findCategoryCode — 카테고리 이름/코드 매칭', () => {
test('검증된 대분류 루트 코드 정확 매치', () => {
const m = findCategoryCode('패션의류');
expect(m[0]).toMatchObject({ code: ROOT_CATEGORY_CODES['패션의류'], source: 'root' });
});
test('부분 일치도 루트 코드로 매치', () => {
const m = findCategoryCode('화장품');
expect(m[0]).toMatchObject({ code: '50000002', source: 'root' });
});
test('숫자만 입력하면 raw cat_id 로 그대로 통과', () => {
const m = findCategoryCode('50012345');
expect(m).toEqual([{ name: '50012345', path: '50012345', code: '50012345', source: 'raw' }]);
});
test('트리에 있는 세부 카테고리명은 tree 매치로 코드 반환', () => {
const m = findCategoryCode('원피스');
expect(m.length).toBeGreaterThan(0);
expect(m[0].source).toBe('tree');
expect(m[0].code).toMatch(/^\d+$/);
});
test('빈 문자열은 빈 배열', () => {
expect(findCategoryCode('')).toEqual([]);
});
test('매칭 안 되는 이름은 빈 배열', () => {
expect(findCategoryCode('존재하지않는분야이름절대매칭안됨xyz')).toEqual([]);
});
});
describe('parseShoppingIntent — tolerant JSON 파싱', () => {
test('정상 JSON', async () => {
const ai = mockAi('{"mode":"compare","categories":["패션의류","화장품/미용"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseShoppingIntent(ai, '패션의류랑 화장품 비교해줘', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.mode).toBe('compare');
expect(r.categories).toEqual(['패션의류', '화장품/미용']);
});
test('코드펜스로 감싼 JSON', async () => {
const ai = mockAi('설명입니다\n```json\n{"mode":"discover","categories":["뷰티","디지털/가전"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}\n```');
const r = await parseShoppingIntent(ai, '요즘 뜨는 카테고리', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.mode).toBe('discover');
expect(r.categories).toEqual(['뷰티', '디지털/가전']);
});
test('완전히 깨진 응답 → compare 모드로 원문 폴백', async () => {
const ai = mockAi('이건 JSON이 아닙니다 그냥 텍스트');
const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04');
expect(r.parsed).toBe(false);
expect(r.mode).toBe('compare');
expect(r.categories).toEqual(['패션의류 트렌드']);
});
test('AI 호출 자체가 실패해도 안전한 기본값으로 폴백(예외 전파 안 함)', async () => {
const ai = mockAi('', true);
const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04');
expect(r.parsed).toBe(false);
expect(r.mode).toBe('compare');
expect(r.categories).toEqual(['패션의류 트렌드']);
});
});
describe('parseSearchTrendIntent — tolerant JSON 파싱', () => {
test('정상 JSON', async () => {
const ai = mockAi('{"mode":"compare","keywords":["원피스","블라우스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
const r = await parseSearchTrendIntent(ai, '원피스랑 블라우스 비교', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.keywords).toEqual(['원피스', '블라우스']);
});
test('balanced-brace 추출 — 앞뒤에 잡텍스트가 섞여도 파싱', async () => {
const ai = mockAi('물론이죠! {"mode":"discover","keywords":["여름 원피스","린넨 원피스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]} 이상입니다.');
const r = await parseSearchTrendIntent(ai, '20대 여성 원피스 인기 키워드', '2026-08-04');
expect(r.parsed).toBe(true);
expect(r.mode).toBe('discover');
expect(r.keywords).toEqual(['여름 원피스', '린넨 원피스']);
});
test('빈 응답 → compare 모드로 원문 폴백', async () => {
const ai = mockAi('');
const r = await parseSearchTrendIntent(ai, '원피스 키워드', '2026-08-04');
expect(r.parsed).toBe(false);
expect(r.mode).toBe('compare');
expect(r.keywords).toEqual(['원피스 키워드']);
});
});
+97
View File
@@ -0,0 +1,97 @@
/**
* [v2.2.313] 파트별 완성 — 길이 한계에 닿은 보고서를 남은 섹션 단위로 나눠 생성해
* 한 문서로 통합. "더 좁은 주제로 나눠 질문하세요" 떠넘기기의 대체.
*/
import {
completeSectioned,
parseOutlineTitles,
dropAlreadyWritten,
} from '../src/agent/handlePrompt/sectionedCompletion';
import type { ChatMessage } from '../src/agent';
const BASE: ChatMessage[] = [
{ role: 'system', content: 'SYSTEM CONTEXT (파일 근거: agent.ts, memoryContext.ts)' },
{ role: 'user', content: '프로젝트 분석해줘' },
];
describe('parseOutlineTitles / dropAlreadyWritten', () => {
test('불릿·번호·헤딩 마커를 벗기고 제목만 추출', () => {
const raw = '- 구조·현황\n2) 핵심 발견\n## 다음 단계\n\n없음이 아님';
expect(parseOutlineTitles(raw, 4)).toEqual(['구조·현황', '핵심 발견', '다음 단계', '없음이 아님']);
});
test('"없음" 단독 응답은 빈 목록', () => {
expect(parseOutlineTitles('없음', 4)).toEqual([]);
});
test('초안에 이미 있는 섹션은 제외 (공백 무시 매치)', () => {
const titles = ['핵심 발견', '다음 단계'];
expect(dropAlreadyWritten(titles, '## 핵심 발견\n이미 썼다')).toEqual(['다음 단계']);
});
});
describe('completeSectioned', () => {
test('outline → 섹션별 생성 → 한 문서로 통합', async () => {
const calls: string[] = [];
const r = await completeSectioned({
draft: '## 개요\n잘린 초안입니다.',
userPrompt: '프로젝트 분석해줘',
baseMessages: BASE,
requiredElements: ['구조·현황', '다음 단계'],
callLLM: async (messages) => {
const last = String(messages[messages.length - 1].content);
calls.push(last.slice(0, 30));
if (last.includes('남은 섹션 제목')) return '구조·현황\n다음 단계';
if (last.includes('"구조·현황"')) return '## 구조·현황\nsrc/agent.ts 중심 구조.';
if (last.includes('"다음 단계"')) return '테스트 보강.'; // 헤딩 없이 와도
return '';
},
});
expect(r.sectionsAdded).toBe(2);
expect(r.merged).toContain('잘린 초안입니다.');
expect(r.merged).toContain('## 구조·현황');
expect(r.merged).toContain('## 다음 단계\n테스트 보강.'); // 헤딩 자동 보강
// 섹션 호출에 시스템 컨텍스트가 재사용됐는지 (근거 접근 유지)
expect(calls.length).toBe(3); // outline 1 + 섹션 2
});
test('outline 이 "없음"이면 초안 유지', async () => {
const r = await completeSectioned({
draft: '완결된 초안'.repeat(10),
userPrompt: 'q',
baseMessages: BASE,
callLLM: async () => '없음',
});
expect(r.sectionsAdded).toBe(0);
expect(r.merged).toContain('완결된 초안');
});
test('한 섹션 생성 실패는 건너뛰고 나머지로 통합', async () => {
const r = await completeSectioned({
draft: '초안 본문입니다.',
userPrompt: 'q',
baseMessages: BASE,
callLLM: async (messages) => {
const last = String(messages[messages.length - 1].content);
if (last.includes('남은 섹션 제목')) return 'A섹션\nB섹션';
if (last.includes('"A섹션"')) throw new Error('LLM down');
return '## B섹션\n본문';
},
});
expect(r.sectionsAdded).toBe(1);
expect(r.merged).toContain('## B섹션');
});
test('전 섹션 실패 시 초안 그대로 (폴백)', async () => {
const r = await completeSectioned({
draft: '초안',
userPrompt: 'q',
baseMessages: BASE,
callLLM: async (messages) => {
const last = String(messages[messages.length - 1].content);
if (last.includes('남은 섹션 제목')) return 'A섹션';
throw new Error('down');
},
});
expect(r.sectionsAdded).toBe(0);
expect(r.merged).toBe('초안');
});
});
+57
View File
@@ -0,0 +1,57 @@
/**
* [v2.2.318] 하위 질문 체크리스트 — "물음 3개에 하나도 직접 답하지 않는" 실사례 가드.
*/
import {
extractSubQuestions,
clauseKeyTokens,
buildSubQuestionBlock,
checkSubQuestionCoverage,
formatSubQuestionFooter,
} from '../src/intelligence/subQuestions';
const PROMPT = 'https://www.koritips.com 분석해줘. 어떠한 주제로 블로그가 운영되고 있는지, 글의 퀄리티는 어떠한지, 검색해서 들어온 사람이 글을 읽고 도움이 되었을지';
describe('extractSubQuestions', () => {
test('실사례 프롬프트에서 물음 3개 추출', () => {
const qs = extractSubQuestions(PROMPT);
expect(qs.length).toBe(3);
expect(qs[0]).toContain('주제');
expect(qs[1]).toContain('퀄리티');
expect(qs[2]).toContain('도움');
});
test('물음이 1개뿐이면 빈 목록 (본문 전체가 답)', () => {
expect(extractSubQuestions('이 코드가 왜 느린지 알려줘')).toEqual([]);
expect(extractSubQuestions('안녕하세요')).toEqual([]);
});
});
describe('clauseKeyTokens / coverage', () => {
test('특징 토큰에서 조사·의문사 제거', () => {
const keys = clauseKeyTokens('글의 퀄리티는 어떠한지');
expect(keys).toContain('퀄리티');
expect(keys).not.toContain('어떠한');
});
test('실사례 답변(평가 형용사만) → 퀄리티·도움 물음 누락 감지', () => {
const answer = 'koritips.com은 기술 아카이브로서의 가치가 매우 높습니다. 신뢰성과 지속 가능성, 타겟 명확성 측면에서 높은 평가를 받을 수 있습니다.';
const cov = checkSubQuestionCoverage(PROMPT, answer);
expect(cov.total).toBe(3);
expect(cov.missing.length).toBeGreaterThanOrEqual(2);
expect(formatSubQuestionFooter(cov)).toContain('미답변 물음');
});
test('각 물음에 답한 답변은 통과', () => {
const answer = '주제는 블로그 운영 팁입니다. 글의 퀄리티는 단계별 스크린샷이 있어 높습니다. 검색 유입자에게 실질적 도움이 됩니다.';
expect(checkSubQuestionCoverage(PROMPT, answer).missing).toEqual([]);
});
});
describe('buildSubQuestionBlock', () => {
test('물음 2개 이상이면 체크리스트 블록 생성', () => {
const blk = buildSubQuestionBlock(PROMPT);
expect(blk).toContain('[하위 질문 체크리스트');
expect(blk).toContain('1.');
expect(blk).toContain('구체 근거');
});
test('단일 질문은 빈 문자열', () => {
expect(buildSubQuestionBlock('서버 상태 확인해줘')).toBe('');
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* [v2.2.316] 턴 의도 브리핑 — "질문 의도를 판단하지 못한다" 문제의 구조적 해결.
* 게이팅(잡담 제외) / JSON 파싱 내성 / 주입 블록의 근거-경로 강제를 검증.
*/
import {
shouldBuildTurnIntentBrief,
parseIntentBrief,
buildTurnIntentBrief,
formatTurnIntentBlock,
} from '../src/intelligence/turnIntentBrief';
describe('shouldBuildTurnIntentBrief — 게이팅', () => {
test('실질 요청형 턴은 대상', () => {
expect(shouldBuildTurnIntentBrief('E:\Wiki\connectai 프로젝트 분석하고 보고해줘', false)).toBe(true);
expect(shouldBuildTurnIntentBrief('HTTPS://KORITIPS.COM 사이트에 대한 분석 및 평가해줘', false)).toBe(true);
});
test('잡담·초단문·슬래시 명령은 제외', () => {
expect(shouldBuildTurnIntentBrief('잘 지내?', true)).toBe(false);
expect(shouldBuildTurnIntentBrief('응 고마워', false)).toBe(false);
expect(shouldBuildTurnIntentBrief('/wikify https://x.com 정리해줘', false)).toBe(false);
});
});
describe('parseIntentBrief — 파싱 내성', () => {
test('코드펜스·잡담 섞인 응답에서도 JSON 추출', () => {
const raw = '알겠습니다.\n```json\n{"purpose":"사이트 품질 평가","realQuestions":["구독자층"],"deliverable":"평가 보고","audience":"본인","accessPath":"web","constraints":""}\n```';
const b = parseIntentBrief(raw)!;
expect(b.purpose).toBe('사이트 품질 평가');
expect(b.accessPath).toBe('web');
});
test('purpose 없는 JSON / 비 JSON 은 null', () => {
expect(parseIntentBrief('{"realQuestions":[]}')).toBeNull();
expect(parseIntentBrief('그냥 텍스트')).toBeNull();
});
});
describe('buildTurnIntentBrief — 호출·타임아웃', () => {
test('정상 응답 → 브리핑 객체', async () => {
const b = await buildTurnIntentBrief({
prompt: '사이트 분석해줘',
llm: async () => '{"purpose":"P","realQuestions":["Q"],"deliverable":"D","audience":"A","accessPath":"web","constraints":""}',
});
expect(b?.purpose).toBe('P');
});
test('LLM 실패 → null (턴을 막지 않음)', async () => {
const b = await buildTurnIntentBrief({ prompt: 'x분석해줘', llm: async () => { throw new Error('down'); } });
expect(b).toBeNull();
});
test('타임아웃 → null', async () => {
const b = await buildTurnIntentBrief({
prompt: 'x분석해줘',
timeoutMs: 50,
llm: () => new Promise((res) => setTimeout(() => res('{}'), 500)),
});
expect(b).toBeNull();
});
});
describe('formatTurnIntentBlock — 근거 경로 강제', () => {
const base = { purpose: '사이트 평가', realQuestions: ['구독자층'], deliverable: '평가 보고', audience: '본인', constraints: '' };
test('web → 실데이터 근거 강제 + 일반론 금지', () => {
const blk = formatTurnIntentBlock({ ...base, accessPath: 'web' });
expect(blk).toContain('[질문자 의도 브리핑');
expect(blk).toContain('URL CONTENT');
expect(blk).toContain('일반 지식으로 사이트를 평하는 것은 금지');
});
test('local-files → 파일 정독 강제', () => {
expect(formatTurnIntentBlock({ ...base, accessPath: 'local-files' })).toContain('investigate_files');
});
test('null 브리핑 → 빈 문자열', () => {
expect(formatTurnIntentBlock(null)).toBe('');
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* [v2.2.315] 웹 실접속 결정론 표시 — "사이트 분석 요청에 실접속 없이 일반론 답변"
* (koritips.com 실사례) 재발 방지. 접속 실패는 답변 상단 경고, 성공은 하단 출처.
*/
import { buildWebFetchNotice, isUrlContextSuccessBlock } from '../src/lib/contextBuilders/urlContext';
import { shouldUseMultiAgentWorkflow } from '../src/lib/contextBuilders/multiAgentRouting';
describe('buildWebFetchNotice', () => {
test('전부 실패 → 상단 경고 (실측 아님 명시)', () => {
const n = buildWebFetchNotice({ urls: ['https://koritips.com'], succeeded: [], failed: [{ url: 'https://koritips.com', reason: 'HTTP 403' }] });
expect(n.top).toContain('사이트 실접속 실패');
expect(n.top).toContain('koritips.com');
expect(n.top).toContain('HTTP 403');
expect(n.bottom).toBe('');
});
test('성공 → 하단 실접속 출처', () => {
const n = buildWebFetchNotice({ urls: ['https://koritips.com'], succeeded: ['https://koritips.com'], failed: [] });
expect(n.top).toBe('');
expect(n.bottom).toContain('실접속 확인');
});
test('URL 없는 턴은 무표시', () => {
expect(buildWebFetchNotice(null)).toEqual({ top: '', bottom: '' });
expect(buildWebFetchNotice({ urls: [], succeeded: [], failed: [] })).toEqual({ top: '', bottom: '' });
});
test('성공 블록 판별', () => {
expect(isUrlContextSuccessBlock('[URL CONTENT — 실데이터 · https://x.com]\n...')).toBe(true);
expect(isUrlContextSuccessBlock('[URL CONTENT — https://x.com]\n상태: 접근 실패 — ...')).toBe(false);
});
});
describe('shouldUseMultiAgentWorkflow — URL 은 단일 턴 강제', () => {
test('URL 포함 조사 요청은 워크플로우로 빠지지 않는다 (웹 접근 경로가 없으므로)', () => {
expect(shouldUseMultiAgentWorkflow('HTTPS://KORITIPS.COM 사이트 조사해서 보고서 작성해줘', true)).toBe(false);
expect(shouldUseMultiAgentWorkflow('https://example.com 리서치해줘', false)).toBe(false);
});
});
// [v2.2.317] 본문 불일치 검증 — 접속 성공인데 실데이터 미반영 일반론 답변 감지.
import { detectWebContentMismatch, formatWebContentMismatchFooter, extractSuccessMeta } from '../src/lib/contextBuilders/urlContext';
describe('detectWebContentMismatch', () => {
const stats = {
urls: ['https://www.koritips.com'],
succeeded: ['https://www.koritips.com'],
failed: [],
succeededDetail: [{
url: 'https://www.koritips.com',
title: "Kori's Tips - Lived. Learned. Shared.",
sample: 'Blogging Tips 카테고리의 글 모음. 블로그 운영, 애드센스 수익, 워드프레스 세팅, 검색 노출 가이드를 다룹니다. blogging adsense wordpress guide',
}],
};
const hallucinated = '이 사이트는 IT 엔지니어 및 개발자를 위한 전문 지식 공유 플랫폼으로 판단됩니다. 기술 기획자와 테크프리너가 핵심 구독층이며, 아키텍처 관점의 심층 콘텐츠를 제공합니다. '.repeat(4);
test('실데이터 표현이 전혀 반영 안 된 일반론 = 불일치', () => {
const r = detectWebContentMismatch(hallucinated, stats);
expect(r.mismatch).toBe(true);
});
test('본문 표현을 인용한 답변은 통과', () => {
const grounded = `이 블로그는 Blogging Tips 카테고리 중심으로 블로그 운영과 애드센스 수익, 워드프레스 세팅을 다룹니다. ${'상세 설명. '.repeat(30)}`;
expect(detectWebContentMismatch(grounded, stats).mismatch).toBe(false);
});
test('접속 실패 턴은 판정 대상 아님 (실패는 상단 경고가 담당)', () => {
expect(detectWebContentMismatch(hallucinated, { urls: ['https://x.com'], succeeded: [], failed: [{ url: 'https://x.com', reason: 'x' }] }).mismatch).toBe(false);
});
test('footer 에 재요청 안내 포함', () => {
expect(formatWebContentMismatchFooter()).toContain('본문 불일치');
});
});
describe('extractSuccessMeta', () => {
test('성공 블록에서 제목·본문 표본 추출', () => {
const block = '[URL CONTENT — 실데이터 · https://x.com]\n제목: 테스트 제목\n아래 본문만 근거로 답하라.\n\n본문 내용입니다.';
const m = extractSuccessMeta('https://x.com', block);
expect(m.title).toBe('테스트 제목');
expect(m.sample).toContain('본문 내용');
});
});