Files
xk-ai-agent/internal/kb/parser_test.go
2026-08-14 21:50:48 +08:00

306 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package kb
// ========================================================================
// 多格式解析器单元测试
// ========================================================================
// 测试样本全部在内存里构造docx 手工拼 zip、pdf 手工拼对象表、
// xlsx 用 excelize 生成不依赖外部测试文件CI 环境可直接跑
// ========================================================================
import (
"archive/zip"
"bytes"
"fmt"
"strings"
"testing"
"unicode/utf8"
"github.com/xuri/excelize/v2"
)
// buildMiniDocx 在内存构造一个最小可用的 docxzip + word/document.xml
func buildMiniDocx(t *testing.T, documentXML string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("word/document.xml")
if err != nil {
t.Fatalf("创建 zip 条目失败: %v", err)
}
if _, err := w.Write([]byte(documentXML)); err != nil {
t.Fatalf("写入 document.xml 失败: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("关闭 zip 失败: %v", err)
}
return buf.Bytes()
}
// TestExtractDocxText 验证 docx 抽取:段落分隔、换行、制表符、表格单元格
func TestExtractDocxText(t *testing.T) {
xml := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>感冒的中医辨证</w:t></w:r></w:p>
<w:p><w:r><w:t>风寒感冒:</w:t><w:br/><w:t>恶寒重发热轻</w:t><w:tab/><w:t>无汗头痛</w:t></w:r></w:p>
<w:tbl>
<w:tr><w:tc><w:p><w:r><w:t>药名</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>麻黄</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
</w:body>
</w:document>`
data := buildMiniDocx(t, xml)
text, err := extractDocxText(data)
if err != nil {
t.Fatalf("extractDocxText 报错: %v", err)
}
for _, want := range []string{"感冒的中医辨证", "风寒感冒:\n恶寒重发热轻\t无汗头痛", "药名", "麻黄"} {
if !strings.Contains(text, want) {
t.Errorf("抽取文本缺少 %q实际:\n%s", want, text)
}
}
// 段落之间必须有空行ChunkPlainText 依赖双换行切段)
if !strings.Contains(text, "感冒的中医辨证\n\n") {
t.Errorf("段落后缺少空行分隔,实际:\n%s", text)
}
}
// TestExtractDocxText_InvalidZip 非 zip 内容要报友好错误而不是 panic
func TestExtractDocxText_InvalidZip(t *testing.T) {
if _, err := extractDocxText([]byte("这不是一个zip文件")); err == nil {
t.Fatal("非法 docx 应当报错")
}
}
// TestExtractHTMLText 验证 HTML 抽取:跳过 script/style、标题转 #、列表、表格
func TestExtractHTMLText(t *testing.T) {
htmlSrc := `<!DOCTYPE html>
<html><head><title>页面标题</title><style>.a{color:red}</style><script>alert(1)</script></head>
<body>
<nav>导航栏不要</nav>
<h1>中医基础理论</h1>
<p>阴阳五行学说是中医的理论基础。</p>
<h2>四诊</h2>
<ul><li>望诊</li><li>闻诊</li></ul>
<table><tr><td>寒证</td><td>热证</td></tr></table>
</body></html>`
text, err := extractHTMLText([]byte(htmlSrc))
if err != nil {
t.Fatalf("extractHTMLText 报错: %v", err)
}
if strings.Contains(text, "alert") || strings.Contains(text, "color:red") || strings.Contains(text, "导航栏不要") {
t.Errorf("script/style/nav 内容未被过滤,实际:\n%s", text)
}
if !strings.Contains(text, "# 中医基础理论") || !strings.Contains(text, "## 四诊") {
t.Errorf("标题未转成 markdown 井号,实际:\n%s", text)
}
if !strings.Contains(text, "- 望诊") {
t.Errorf("列表项缺少 - 前缀,实际:\n%s", text)
}
// 走 ChunkMarkdown 后标题应进入 chunk.title
chunks := ChunkMarkdown(text, DefaultChunkOptions())
foundTitle := false
for _, c := range chunks {
if c.Title == "中医基础理论" || c.Title == "四诊" {
foundTitle = true
}
}
if !foundTitle {
t.Errorf("ChunkMarkdown 未把 HTML 标题切进 chunk.titlechunks=%+v", chunks)
}
}
// buildMiniPDF 在内存构造一个最小合法 PDF单页 + Helvetica + 一行文本)
//
// 手工维护对象偏移量表xref这是 PDF 规范要求的最小骨架
func buildMiniPDF(text string) []byte {
content := fmt.Sprintf("BT /F1 12 Tf 72 720 Td (%s) Tj ET", text)
objs := []string{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
}
var buf bytes.Buffer
buf.WriteString("%PDF-1.4\n")
offsets := make([]int, len(objs)+1)
for i, o := range objs {
offsets[i+1] = buf.Len()
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", i+1, o)
}
xrefPos := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n", len(objs)+1)
buf.WriteString("0000000000 65535 f \n")
for i := 1; i <= len(objs); i++ {
fmt.Fprintf(&buf, "%010d 00000 n \n", offsets[i])
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", len(objs)+1, xrefPos)
return buf.Bytes()
}
// TestExtractPDFText 验证文字版 PDF 能抽出文本
func TestExtractPDFText(t *testing.T) {
data := buildMiniPDF("Mahuang Decoction for cold")
text, err := extractPDFText(data)
if err != nil {
t.Fatalf("extractPDFText 报错: %v", err)
}
if !strings.Contains(text, "Mahuang") {
t.Errorf("PDF 文本抽取结果不含预期内容,实际: %q", text)
}
}
// TestExtractPDFText_Invalid 畸形 PDF 要报错而不是 panic
func TestExtractPDFText_Invalid(t *testing.T) {
if _, err := extractPDFText([]byte("%PDF-1.4 这是坏文件")); err == nil {
t.Fatal("畸形 PDF 应当报错")
}
}
// TestDecodeToUTF8_GBK 验证 GBK 字节流自动转码(「中医药」的 GBK 编码)
func TestDecodeToUTF8_GBK(t *testing.T) {
gbk := []byte{0xD6, 0xD0, 0xD2, 0xBD, 0xD2, 0xA9}
if got := decodeToUTF8(gbk); got != "中医药" {
t.Errorf("GBK 解码失败got=%q", got)
}
// 合法 UTF-8 原样保留
if got := decodeToUTF8([]byte("中医药")); got != "中医药" {
t.Errorf("UTF-8 被误转码got=%q", got)
}
// UTF-8 BOM 被剥掉
withBOM := append([]byte{0xEF, 0xBB, 0xBF}, []byte("中医药")...)
if got := decodeToUTF8(withBOM); got != "中医药" {
t.Errorf("UTF-8 BOM 未剥离got=%q", got)
}
}
// TestParseCSV 验证 CSV 解析(含引号字段与不等列数容错)
func TestParseCSV(t *testing.T) {
csvData := []byte("药名,性味,功效\n麻黄,辛温,\"发汗解表,宣肺平喘\"\n桂枝,辛甘温\n")
chunks, raw, err := parseCSV(csvData, DefaultChunkOptions())
if err != nil {
t.Fatalf("parseCSV 报错: %v", err)
}
if len(chunks) == 0 {
t.Fatal("parseCSV 未产出 chunk")
}
if !strings.Contains(raw, "麻黄 | 辛温 | 发汗解表,宣肺平喘") {
t.Errorf("CSV 行未按「 | 」拼接raw:\n%s", raw)
}
if !strings.Contains(raw, "桂枝 | 辛甘温") {
t.Errorf("不等列数的行解析失败raw:\n%s", raw)
}
}
// TestParseExcel_MaxKB 三列带表头 → 保持 MaxKB 语义(行即 chunk
func TestParseExcel_MaxKB(t *testing.T) {
f := excelize.NewFile()
sheet := f.GetSheetName(0)
_ = f.SetSheetRow(sheet, "A1", &[]interface{}{"分段标题", "分段内容", "问题"})
_ = f.SetSheetRow(sheet, "A2", &[]interface{}{"麻黄汤", "主治外感风寒表实证", "感冒怎么办;风寒感冒用什么方"})
buf, err := f.WriteToBuffer()
if err != nil {
t.Fatalf("生成 xlsx 失败: %v", err)
}
chunks, _, err := parseExcel(buf.Bytes(), DefaultChunkOptions())
if err != nil {
t.Fatalf("parseExcel 报错: %v", err)
}
if len(chunks) != 1 {
t.Fatalf("MaxKB 模式应产出 1 个 chunk实际 %d 个", len(chunks))
}
if chunks[0].Title != "麻黄汤" || !strings.Contains(chunks[0].Content, "外感风寒") {
t.Errorf("MaxKB 三列语义解析错误: %+v", chunks[0])
}
if chunks[0].Meta == nil {
t.Errorf("related_questions 未写入 meta")
}
}
// TestParseExcel_Generic 四列以上无表头 → 通用表格模式sheet 名为 chunk 标题)
func TestParseExcel_Generic(t *testing.T) {
f := excelize.NewFile()
sheet := f.GetSheetName(0)
_ = f.SetSheetName(sheet, "常用中药")
_ = f.SetSheetRow("常用中药", "A1", &[]interface{}{"麻黄", "辛温", "肺经", "发汗解表"})
_ = f.SetSheetRow("常用中药", "A2", &[]interface{}{"桂枝", "辛甘温", "心经", "温通经脉"})
buf, err := f.WriteToBuffer()
if err != nil {
t.Fatalf("生成 xlsx 失败: %v", err)
}
chunks, raw, err := parseExcel(buf.Bytes(), DefaultChunkOptions())
if err != nil {
t.Fatalf("parseExcel 报错: %v", err)
}
if len(chunks) == 0 {
t.Fatal("通用表格模式未产出 chunk")
}
if chunks[0].Title != "常用中药" {
t.Errorf("通用模式 chunk 标题应为 sheet 名,实际 %q", chunks[0].Title)
}
if !strings.Contains(raw, "麻黄 | 辛温 | 肺经 | 发汗解表") {
t.Errorf("通用表格行拼接错误raw:\n%s", raw)
}
}
// TestTruncateRunes 验证查询截断不破坏 UTF-8 字符边界
func TestTruncateRunes(t *testing.T) {
if got := truncateRunes("麻黄汤治感冒", 3); got != "麻黄汤" {
t.Errorf("中文截断错误got=%q", got)
}
if got := truncateRunes("短查询", 500); got != "短查询" {
t.Errorf("短于上限不应截断got=%q", got)
}
if got := truncateRunes("abc", 0); got != "abc" {
t.Errorf("max<=0 应原样返回got=%q", got)
}
// 截断结果必须仍是合法 UTF-8
long := strings.Repeat("风寒感冒", 200)
if cut := truncateRunes(long, 500); !utf8.ValidString(cut) || utf8.RuneCountInString(cut) != 500 {
t.Errorf("长文本截断错误len=%d valid=%v", utf8.RuneCountInString(cut), utf8.ValidString(cut))
}
}
// TestParseFileFromBytes_Dispatch 验证扩展名分发与防御逻辑
func TestParseFileFromBytes_Dispatch(t *testing.T) {
// docx 走 docx 分支
docx := buildMiniDocx(t, `<w:document xmlns:w="x"><w:body><w:p><w:r><w:t>四君子汤补气健脾</w:t></w:r></w:p></w:body></w:document>`)
pdoc, err := ParseFileFromBytes("方剂.docx", docx, DefaultChunkOptions())
if err != nil {
t.Fatalf("docx 分发失败: %v", err)
}
if pdoc.SourceType != "docx" || len(pdoc.Chunks) == 0 {
t.Errorf("docx 解析结果异常: type=%s chunks=%d", pdoc.SourceType, len(pdoc.Chunks))
}
// 老版 .doc 明确拒绝
if _, err := ParseFileFromBytes("旧文档.doc", []byte("x"), DefaultChunkOptions()); err == nil {
t.Error(".doc 应当被拒绝")
}
// 不支持的扩展名报错
if _, err := ParseFileFromBytes("图片.png", []byte("x"), DefaultChunkOptions()); err == nil {
t.Error("png 应当被拒绝")
}
// 空文本文件:解析成功但没有 chunk → 报「没有可导入的文本内容」
if _, err := ParseFileFromBytes("空.txt", []byte(" \n\n "), DefaultChunkOptions()); err == nil {
t.Error("空文件应当报错")
}
// html 走 html 分支且标题进 chunk.title
html := []byte(`<html><body><h1>温病条辨</h1><p>太阴风温、温热、温疫、冬温,初起恶风寒者,桂枝汤主之。</p></body></html>`)
pdoc, err = ParseFileFromBytes("wenbing.html", html, DefaultChunkOptions())
if err != nil {
t.Fatalf("html 分发失败: %v", err)
}
if pdoc.SourceType != "html" || len(pdoc.Chunks) == 0 || pdoc.Chunks[0].Title != "温病条辨" {
t.Errorf("html 解析结果异常: type=%s chunks=%+v", pdoc.SourceType, pdoc.Chunks)
}
}