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('표면 질문만 문자적으로 답하지 마라'); }); });