|
@@ -0,0 +1,279 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * LLM 响应清洗器 单元测试
|
|
|
|
|
+ *
|
|
|
|
|
+ * 测试 response-cleaner.ts 的所有边界情况。
|
|
|
|
|
+ * 这是 LLM 调用后的最后一道防线,如果这里出问题,DB 会存垃圾。
|
|
|
|
|
+ */
|
|
|
|
|
+import { describe, it, expect } from 'vitest';
|
|
|
|
|
+import { cleanLlmResponse, cleanLlmShortText, extractJsonFromResponse } from '@/services/llm/response-cleaner';
|
|
|
|
|
+
|
|
|
|
|
+describe('cleanLlmResponse - LLM 响应清洗', () => {
|
|
|
|
|
+ describe('基础输入', () => {
|
|
|
|
|
+ it('普通文本 → 原样返回', () => {
|
|
|
|
|
+ const result = cleanLlmResponse('这是一段普通的正文内容。');
|
|
|
|
|
+ expect(result).toBe('这是一段普通的正文内容。');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('空字符串 → 空字符串', () => {
|
|
|
|
|
+ expect(cleanLlmResponse('')).toBe('');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('null/undefined → 不崩溃,原样返回', () => {
|
|
|
|
|
+ expect(cleanLlmResponse(null as any)).toBe(null);
|
|
|
|
|
+ expect(cleanLlmResponse(undefined as any)).toBe(undefined);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('思考标签清除(关键功能)', () => {
|
|
|
|
|
+ it('清除 <think> 成对标签及内容', () => {
|
|
|
|
|
+ const input = '可见开头。<think>内部思考过程</think>可见结尾。';
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).not.toContain('内部思考过程');
|
|
|
|
|
+ expect(result).toContain('可见开头');
|
|
|
|
|
+ expect(result).toContain('可见结尾');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('清除 <thinking> 成对标签', () => {
|
|
|
|
|
+ const input = '正文A<thinking>思考内容B</thinking>正文C';
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).not.toContain('思考内容B');
|
|
|
|
|
+ expect(result).toContain('正文A');
|
|
|
|
|
+ expect(result).toContain('正文C');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('清除跨多行的 think 块', () => {
|
|
|
|
|
+ const input = '正文开始\n<think>\n思考第一行\n思考第二行\n更多思考\n</think>\n正文继续';
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).not.toContain('思考第一行');
|
|
|
|
|
+ expect(result).not.toContain('思考第二行');
|
|
|
|
|
+ expect(result).toContain('正文开始');
|
|
|
|
|
+ expect(result).toContain('正文继续');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('清除多个并行的 think 块', () => {
|
|
|
|
|
+ const input = 'A<think>X</think>B<think>Y</think>C';
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).toBe('ABC');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('清除未闭合的 think 标签', () => {
|
|
|
|
|
+ // 没闭合的 think 应该把后面全部清掉(避免用户看到思考)
|
|
|
|
|
+ const input = '正文开头。<think>思考开始但没结束';
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).not.toContain('思考开始但没结束');
|
|
|
|
|
+ expect(result).toContain('正文开头');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('清除自闭合 think 标签', () => {
|
|
|
|
|
+ const input = 'A<think/>B';
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).toBe('AB');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('清除孤立的闭标签', () => {
|
|
|
|
|
+ const input = 'A</think>B';
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).toBe('AB');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('空白处理', () => {
|
|
|
|
|
+ it('连续多个换行 → 压缩为 2 个', () => {
|
|
|
|
|
+ const result = cleanLlmResponse('A\n\n\n\nB');
|
|
|
|
|
+ expect(result).toBe('A\n\nB');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('前后空白 → trim', () => {
|
|
|
|
|
+ const result = cleanLlmResponse(' \n\n内容\n\n ');
|
|
|
|
|
+ expect(result).toBe('内容');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('真实 LLM 输出场景', () => {
|
|
|
|
|
+ it('MiniMax 长思考+正文 → 只留正文', () => {
|
|
|
|
|
+ const input = `<thinking>
|
|
|
|
|
+让我想想怎么回答这个问题...
|
|
|
|
|
+
|
|
|
|
|
+用户希望了解 AI 发展史,我应该包括:
|
|
|
|
|
+1. 起源
|
|
|
|
|
+2. 发展
|
|
|
|
|
+3. 未来
|
|
|
|
|
+
|
|
|
|
|
+我先写一个提纲。
|
|
|
|
|
+</thinking>
|
|
|
|
|
+
|
|
|
|
|
+# AI 发展史
|
|
|
|
|
+
|
|
|
|
|
+## 第一章:起源
|
|
|
|
|
+
|
|
|
|
|
+AI 的起源可以追溯到 1950 年代...`;
|
|
|
|
|
+
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).not.toContain('让我想想');
|
|
|
|
|
+ expect(result).not.toContain('起源\n2');
|
|
|
|
|
+ expect(result).toContain('AI 发展史');
|
|
|
|
|
+ expect(result).toContain('AI 的起源');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('Claude 风格的 thinking 块', () => {
|
|
|
|
|
+ const input = '<antthinking>内部思考</antthinking>正文内容';
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).toBe('正文内容');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('纯思考标签无内容 → 返回空字符串(绝不能写进 DB)', () => {
|
|
|
|
|
+ const result = cleanLlmResponse('<think>只有思考</think>');
|
|
|
|
|
+ expect(result).toBe('');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('双括号风格的 [think] 块', () => {
|
|
|
|
|
+ const input = 'A[think]思考[/think]B';
|
|
|
|
|
+ const result = cleanLlmResponse(input);
|
|
|
|
|
+ expect(result).toBe('AB');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+describe('extractJsonFromResponse - JSON 提取', () => {
|
|
|
|
|
+ describe('合法 JSON', () => {
|
|
|
|
|
+ it('纯 JSON 对象 → 解析', () => {
|
|
|
|
|
+ expect(extractJsonFromResponse('{"title":"测试"}')).toEqual({ title: '测试' });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('JSON 数组 → 解析', () => {
|
|
|
|
|
+ expect(extractJsonFromResponse('[1,2,3]')).toEqual([1, 2, 3]);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('代码块包裹 JSON → 提取解析', () => {
|
|
|
|
|
+ const input = '这是返回\n```json\n{"title":"测试","count":5}\n```\n结束';
|
|
|
|
|
+ expect(extractJsonFromResponse(input)).toEqual({ title: '测试', count: 5 });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('无语言标记代码块 → 也解析', () => {
|
|
|
|
|
+ const input = '```\n{"a":1}\n```';
|
|
|
|
|
+ expect(extractJsonFromResponse(input)).toEqual({ a: 1 });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('嵌套 JSON → 解析', () => {
|
|
|
|
|
+ const json = '{"chapters":[{"number":1,"title":"测试","sections":[]}]}';
|
|
|
|
|
+ expect(extractJsonFromResponse(json)).toEqual({
|
|
|
|
|
+ chapters: [{ number: 1, title: '测试', sections: [] }]
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('含 thinking 的 JSON', () => {
|
|
|
|
|
+ it('LLM 返回 thinking + JSON 代码块 → 提取 JSON', () => {
|
|
|
|
|
+ const input = `用户问题
|
|
|
|
|
+
|
|
|
|
|
+<thinking>让我想想大纲怎么写...</thinking>
|
|
|
|
|
+
|
|
|
|
|
+\`\`\`json
|
|
|
|
|
+{"chapters":[{"number":1,"title":"测试"}]}
|
|
|
|
|
+\`\`\``;
|
|
|
|
|
+ expect(extractJsonFromResponse(input)).toEqual({ chapters: [{ number: 1, title: '测试' }] });
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('非法输入', () => {
|
|
|
|
|
+ it('普通描述文字 → 返回 null', () => {
|
|
|
|
|
+ expect(extractJsonFromResponse('不是 JSON,只是描述文字')).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('破损 JSON → 返回 null(不能崩)', () => {
|
|
|
|
|
+ expect(extractJsonFromResponse('{"title": 不完整的')).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('空字符串 → 返回 null', () => {
|
|
|
|
|
+ expect(extractJsonFromResponse('')).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('null/undefined → 返回 null 不崩溃', () => {
|
|
|
|
|
+ expect(extractJsonFromResponse(null as any)).toBeNull();
|
|
|
|
|
+ expect(extractJsonFromResponse(undefined as any)).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('纯思考标签 → 返回 null', () => {
|
|
|
|
|
+ // 清理后是空字符串,parse 失败 → null
|
|
|
|
|
+ expect(extractJsonFromResponse('<think>只有思考</think>')).toBeNull();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('LLM 中文输出', () => {
|
|
|
|
|
+ it('LLM 返回的 JSON 带中文 → 中文正确', () => {
|
|
|
|
|
+ const json = JSON.stringify({ title: 'AI发展史', desc: '讲述AI的故事', tags: ['科技', '历史'] });
|
|
|
|
|
+ const result = extractJsonFromResponse(json);
|
|
|
|
|
+ expect(result).toEqual({
|
|
|
|
|
+ title: 'AI发展史',
|
|
|
|
|
+ desc: '讲述AI的故事',
|
|
|
|
|
+ tags: ['科技', '历史']
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+describe('cleanLlmShortText - 短文本清洗(标题/标签)', () => {
|
|
|
|
|
+ describe('基础清洗', () => {
|
|
|
|
|
+ it('去除首尾空白 → 不变', () => {
|
|
|
|
|
+ expect(cleanLlmShortText(' 标题 ')).toBe('标题');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('去除各种引号包裹', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('"已清洗"')).toBe('已清洗');
|
|
|
|
|
+ expect(cleanLlmShortText('"又清洗"')).toBe('又清洗');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('去除 markdown 粗体', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('**粗体标题**')).toBe('粗体标题');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('去除 markdown 标题标记', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('## 二级标题')).toBe('二级标题');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('去除 markdown 链接', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('[链接文字](http://url.com)')).toBe('链接文字');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('去除 markdown 代码', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('`code`')).toBe('code');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('去除换行 → 转空格', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('第一行\n第二行')).toBe('第一行 第二行');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('去除"书名:"前缀', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('书名:AI发展史')).toBe('AI发展史');
|
|
|
|
|
+ expect(cleanLlmShortText('书名: AI发展史')).toBe('AI发展史');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('异常输入', () => {
|
|
|
|
|
+ it('空字符串 → 返回空字符串', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('')).toBe('');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('null/undefined 不崩溃', () => {
|
|
|
|
|
+ expect(() => cleanLlmShortText(null as any)).not.toThrow();
|
|
|
|
|
+ expect(() => cleanLlmShortText(undefined as any)).not.toThrow();
|
|
|
|
|
+ expect(cleanLlmShortText(null as any)).toBe('');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('纯思考 → 返回空字符串', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('<think>思考</think>')).toBe('');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('截断保护', () => {
|
|
|
|
|
+ it('超长文本 → 截断', () => {
|
|
|
|
|
+ const longText = '一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十';
|
|
|
|
|
+ const result = cleanLlmShortText(longText, { maxLength: 20 });
|
|
|
|
|
+ expect(result.length).toBeLessThanOrEqual(20);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('自定义 maxLength', () => {
|
|
|
|
|
+ expect(cleanLlmShortText('十二个汉字', { maxLength: 6 })).toBe('十二个汉字'.substring(0, 6));
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|