Files
ragflow/internal/ingestion/component/knowledge_compiler/wiki/page_test.go
Zhichang Yu 90f46b0b4d Go port: doc-level metadata extraction and knowledge compiler (#17536)
Ports doc-level auto-metadata extraction to Go and adds the
knowledge_compiler component with scheduler/routing. Fixes Extractor
metadata injection type assertion and enable_metadata default-on.
2026-07-29 21:06:48 +08:00

233 lines
7.4 KiB
Go

package wiki
import (
"strings"
"testing"
)
func TestParseOutline_Basic(t *testing.T) {
md := `# Main Title
Preamble paragraph that introduces the page.
## Background
This is the background section.
## Methods
This is the methods section.
### Subsection
Deeper detail.
## Conclusion
Final thoughts.`
sections := ParseOutline(md)
if len(sections) != 5 {
t.Fatalf("ParseOutline produced %d sections, want 5", len(sections))
}
titles := []string{"Main Title", "Background", "Methods", "Subsection", "Conclusion"}
levels := []int{1, 2, 2, 3, 2}
for i, sec := range sections {
if sec.Title != titles[i] {
t.Errorf("section %d title = %q, want %q", i, sec.Title, titles[i])
}
if sec.Level != levels[i] {
t.Errorf("section %d level = %d, want %d", i, sec.Level, levels[i])
}
if strings.TrimSpace(sec.Content) == "" {
t.Errorf("section %d content is empty", i)
}
}
}
func TestParseOutline_Empty(t *testing.T) {
if got := ParseOutline(""); len(got) != 0 {
t.Fatalf("ParseOutline(\"\") = %d sections, want 0", len(got))
}
if got := ParseOutline(" \n\n \n"); len(got) != 0 {
t.Fatalf("ParseOutline(whitespace) = %d sections, want 0", len(got))
}
}
func TestParseOutline_PreambleOnly(t *testing.T) {
// Markdown with no headings at all: the prose still becomes a synthetic
// level-0 section so the page body is not silently dropped.
sections := ParseOutline("Just some prose, no headings here.")
if len(sections) != 1 {
t.Fatalf("preamble-only produced %d sections, want 1", len(sections))
}
if sections[0].Level != 0 || sections[0].Title != "" {
t.Errorf("synthetic preamble = level=%d title=%q, want level=0 title=\"\"", sections[0].Level, sections[0].Title)
}
if !strings.Contains(sections[0].Content, "Just some prose") {
t.Errorf("synthetic preamble content missing prose: %q", sections[0].Content)
}
}
func TestParseOutline_StripsEmphasis(t *testing.T) {
md := "## **Bold heading**"
sections := ParseOutline(md)
if len(sections) != 1 || sections[0].Title != "Bold heading" {
t.Fatalf("emphasis strip: got %#v, want title \"Bold heading\"", sections)
}
}
func TestBuildPageProducts_PageAndSections(t *testing.T) {
md := `# The Page Title
Lead paragraph.
## Section A
A body.
## Section B
B body.
### Subsection B1
Deeper.`
products := buildPageProducts("t1", "d1", md, []string{"c1", "c2"})
if len(products) < 2 {
t.Fatalf("buildPageProducts = %d products, want page + 3 sections (>= 4)", len(products))
}
// First must be the page itself.
page := products[0]
if kind, _ := page.Meta["kind"].(string); kind != "page" {
t.Fatalf("products[0].kind = %q, want \"page\"", page.Meta["kind"])
}
if title, _ := page.Meta["title"].(string); title != "The Page Title" {
t.Errorf("page title = %q, want \"The Page Title\"", title)
}
if slug, _ := page.Meta["slug"].(string); slug != "the-page-title" {
t.Errorf("page slug = %q, want \"the-page-title\"", slug)
}
if ids, _ := page.Meta["source_chunk_ids"].([]string); len(ids) != 2 {
t.Errorf("page source_chunk_ids = %#v, want 2 ids", page.Meta["source_chunk_ids"])
}
// Sections are linked via parent_id = page.ID.
for i, p := range products[1:] {
if p.ParentID != page.ID {
t.Errorf("section[%d].parent_id = %q, want %q", i, p.ParentID, page.ID)
}
if kind, _ := p.Meta["kind"].(string); kind != "section" {
t.Errorf("section[%d].kind = %q, want \"section\"", i, p.Meta["kind"])
}
if _, has := p.Meta["section_level"]; !has {
t.Errorf("section[%d] missing section_level meta", i)
}
if ids, _ := p.Meta["source_chunk_ids"].([]string); len(ids) != 2 {
t.Errorf("section[%d] source_chunk_ids = %#v, want 2 ids", i, p.Meta["source_chunk_ids"])
}
if strings.TrimSpace(p.Content) == "" {
t.Errorf("section[%d] content is empty", i)
}
}
}
func TestBuildPageProducts_NoSections(t *testing.T) {
// A page that contains only preamble: still emits a page product, with
// zero section children (the preamble is rolled into the page summary).
products := buildPageProducts("t1", "d1", "Just prose, no headings.", []string{"c1"})
if len(products) != 1 {
t.Fatalf("preamble-only page = %d products, want 1", len(products))
}
if kind, _ := products[0].Meta["kind"].(string); kind != "page" {
t.Errorf("kind = %q, want page", products[0].Meta["kind"])
}
}
func TestBuildPageProducts_StableIDs(t *testing.T) {
md := "# Title\n\n## A\n\nbody\n"
first := buildPageProducts("t1", "d1", md, []string{"c1"})
second := buildPageProducts("t1", "d1", md, []string{"c1"})
if len(first) != len(second) {
t.Fatalf("length mismatch: %d vs %d", len(first), len(second))
}
for i := range first {
if first[i].ID != second[i].ID {
t.Errorf("product[%d] id not stable: %q vs %q", i, first[i].ID, second[i].ID)
}
}
}
func TestBuildWikiPageProducts_MultiPage(t *testing.T) {
pages := []wikiPageResult{
{
Slug: "entity/alpha",
Title: "Alpha",
PageType: "entity",
ContentRaw: "# Alpha\n\nSee [[concept/beta]].\n\n## Details\n\nAlpha body.",
Content: "# Alpha\n\nSee [Beta](artifact/kb/concept/beta).\n\n## Details\n\nAlpha body.",
SourceChunkIDs: []string{"c1"},
SourceDocIDs: []string{"d1"},
Outlinks: []string{"concept/beta"},
},
{
Slug: "concept/beta",
Title: "Beta",
PageType: "concept",
ContentRaw: "# Beta\n\nBody.",
Content: "# Beta\n\nBody.",
SourceChunkIDs: []string{"c2"},
SourceDocIDs: []string{"d1"},
},
}
products := buildWikiPageProducts("t1", "d1", pages)
if len(products) != 5 {
t.Fatalf("buildWikiPageProducts = %d products, want 5", len(products))
}
page := products[0]
if kind, _ := page.Meta["kind"].(string); kind != "page" {
t.Fatalf("page kind = %q, want page", page.Meta["kind"])
}
if outlinks, _ := page.Meta["outlinks"].([]string); len(outlinks) != 1 || outlinks[0] != "concept/beta" {
t.Fatalf("page outlinks = %#v, want concept/beta", page.Meta["outlinks"])
}
if ids, _ := page.Meta["source_chunk_ids"].([]string); len(ids) != 1 || ids[0] != "c1" {
t.Fatalf("page source_chunk_ids = %#v, want c1", page.Meta["source_chunk_ids"])
}
if ids, _ := page.Meta["source_doc_ids"].([]string); len(ids) != 1 || ids[0] != "d1" {
t.Fatalf("page source_doc_ids = %#v, want d1", page.Meta["source_doc_ids"])
}
}
func TestTransformWikiLinks(t *testing.T) {
rendered, outlinks := transformWikiLinks(
"See [[concept/beta|Beta]] and [[entity/alpha]]. Also [Beta](artifact/kb/concept/beta).",
"kb",
map[string]string{"entity/alpha": "Alpha", "concept/beta": "Beta"},
)
if !strings.Contains(rendered, "(artifact/kb/concept/beta)") || !strings.Contains(rendered, "(artifact/kb/entity/alpha)") {
t.Fatalf("rendered links not rewritten: %q", rendered)
}
if len(outlinks) != 2 {
t.Fatalf("outlinks = %#v, want 2 unique slugs", outlinks)
}
}
func TestSlugify(t *testing.T) {
cases := []struct{ in, want string }{
{"Hello World", "hello-world"},
{" Trim Me ", "trim-me"},
{"Punctuation!@#Stripped", "punctuationstripped"},
{"", "page"},
// Non-ASCII (CJK) letters are preserved (M17), so the slug keeps the
// Chinese characters and the trailing ASCII word.
{"中文 title", "中文-title"},
{"a-b-c", "a-b-c"},
// Punctuation-only title yields a hash fallback, not the bare "page".
{"!@#", "page-8b024aa5"},
}
for _, c := range cases {
if got := slugify(c.in); got != c.want {
t.Errorf("slugify(%q) = %q, want %q", c.in, got, c.want)
}
}
}