Files
geo/server/internal/tenant/app/knowledge_parser.go
T
root dbd7747742 feat: add knowledge markdown formatting and detail retrieval
- Implemented KnowledgeWebsiteMarkdownPrompt for formatting webpage content into Markdown.
- Added GetItemDetail method in KnowledgeHandler to retrieve knowledge item details.
- Introduced KnowledgeDetailDrawer component for displaying knowledge item details in the admin web interface.
- Created MarkdownPreview component for rendering Markdown content.
- Added knowledge chunking and URL parsing logic to handle webpage content extraction and formatting.
- Updated database schema to include markdown_content in knowledge_items table.
2026-04-05 20:41:42 +08:00

515 lines
13 KiB
Go

package app
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"unicode/utf8"
"github.com/extrame/xls"
pdf "github.com/ledongthuc/pdf"
"github.com/xuri/excelize/v2"
"golang.org/x/net/html"
)
var (
whitespacePattern = regexp.MustCompile(`[ \t]+`)
genericKnowledgeMarkdownTitleRegex = regexp.MustCompile(`^第\s*\d+\s*部分$`)
)
const maxKnowledgeSpreadsheetRows = 100000
type knowledgeParsedContent struct {
Text string
Markdown string
}
func parseKnowledgeTextInput(name, content string) *knowledgeParsedContent {
text := normalizeKnowledgeText(content)
return &knowledgeParsedContent{
Text: text,
Markdown: buildKnowledgeMarkdownFromText(name, text),
}
}
func extractKnowledgeContentFromFile(fileName string, content []byte) (*knowledgeParsedContent, error) {
ext := strings.ToLower(filepath.Ext(fileName))
switch ext {
case ".txt", ".json", ".csv":
return extractUTF8KnowledgeText(fileName, content, false)
case ".md", ".markdown":
return extractUTF8KnowledgeText(fileName, content, true)
case ".html", ".htm":
text, err := extractHTMLText(content)
if err != nil {
return nil, err
}
return &knowledgeParsedContent{
Text: text,
Markdown: buildKnowledgeMarkdownFromText(defaultKnowledgeTitle(fileName), text),
}, nil
case ".docx":
text, err := extractDOCXText(content)
if err != nil {
return nil, err
}
return &knowledgeParsedContent{
Text: text,
Markdown: buildKnowledgeMarkdownFromText(defaultKnowledgeTitle(fileName), text),
}, nil
case ".xlsx":
return extractXLSXContent(content)
case ".xls":
return extractXLSContent(content)
case ".pdf":
text, err := extractPDFText(content)
if err != nil {
return nil, err
}
return &knowledgeParsedContent{
Text: text,
Markdown: buildKnowledgeMarkdownFromText(defaultKnowledgeTitle(fileName), text),
}, nil
default:
if utf8.Valid(content) {
return extractUTF8KnowledgeText(fileName, content, false)
}
return nil, fmt.Errorf("unsupported file type %s", ext)
}
}
func extractUTF8KnowledgeText(fileName string, content []byte, preserveMarkdown bool) (*knowledgeParsedContent, error) {
if !utf8.Valid(content) {
return nil, fmt.Errorf("file %s is not valid UTF-8 text", fileName)
}
raw := string(content)
text := normalizeKnowledgeText(raw)
if text == "" {
return nil, fmt.Errorf("file %s contains no readable text", fileName)
}
markdown := buildKnowledgeMarkdownFromText(defaultKnowledgeTitle(fileName), text)
if preserveMarkdown {
normalizedMarkdown := normalizeKnowledgeMarkdown(raw)
if normalizedMarkdown != "" {
markdown = normalizedMarkdown
}
}
return &knowledgeParsedContent{
Text: text,
Markdown: markdown,
}, nil
}
func extractHTMLText(content []byte) (string, error) {
doc, err := html.Parse(bytes.NewReader(content))
if err != nil {
return "", fmt.Errorf("parse html: %w", err)
}
var parts []string
var walk func(*html.Node, bool)
walk = func(node *html.Node, skip bool) {
if node == nil {
return
}
nextSkip := skip
if node.Type == html.ElementNode {
switch strings.ToLower(node.Data) {
case "script", "style", "noscript", "svg":
nextSkip = true
}
}
if node.Type == html.TextNode && !nextSkip {
text := strings.TrimSpace(node.Data)
if text != "" {
parts = append(parts, text)
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
walk(child, nextSkip)
}
}
walk(doc, false)
result := normalizeKnowledgeText(strings.Join(parts, "\n"))
if result == "" {
return "", fmt.Errorf("html contains no readable text")
}
return result, nil
}
func extractDOCXText(content []byte) (string, error) {
reader, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return "", fmt.Errorf("open docx: %w", err)
}
var parts []string
for _, file := range reader.File {
if file.Name != "word/document.xml" {
continue
}
rc, err := file.Open()
if err != nil {
return "", fmt.Errorf("open docx entry: %w", err)
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return "", fmt.Errorf("read docx entry: %w", err)
}
decoder := xml.NewDecoder(bytes.NewReader(data))
for {
token, tokenErr := decoder.Token()
if tokenErr == io.EOF {
break
}
if tokenErr != nil {
return "", fmt.Errorf("decode docx xml: %w", tokenErr)
}
switch typed := token.(type) {
case xml.CharData:
text := strings.TrimSpace(string(typed))
if text != "" {
parts = append(parts, text)
}
}
}
}
if len(parts) == 0 {
return "", fmt.Errorf("docx contains no readable text")
}
return normalizeKnowledgeText(strings.Join(parts, "\n")), nil
}
func extractXLSXContent(content []byte) (*knowledgeParsedContent, error) {
book, err := excelize.OpenReader(bytes.NewReader(content))
if err != nil {
return nil, fmt.Errorf("open xlsx: %w", err)
}
defer func() { _ = book.Close() }()
markdownSections := make([]string, 0)
textSections := make([]string, 0)
for _, sheet := range book.GetSheetList() {
rows, err := book.GetRows(sheet)
if err != nil {
return nil, fmt.Errorf("read xlsx rows: %w", err)
}
cleaned := normalizeSpreadsheetRows(rows)
if len(cleaned) == 0 {
continue
}
markdownSections = append(markdownSections, buildKnowledgeSheetMarkdown(sheet, cleaned))
textSections = append(textSections, buildKnowledgeSheetPlainText(sheet, cleaned))
}
if len(markdownSections) == 0 {
return nil, fmt.Errorf("xlsx contains no readable text")
}
return &knowledgeParsedContent{
Text: normalizeKnowledgeText(strings.Join(textSections, "\n\n")),
Markdown: strings.TrimSpace(strings.Join(markdownSections, "\n\n")),
}, nil
}
func extractXLSContent(content []byte) (*knowledgeParsedContent, error) {
book, err := xls.OpenReader(bytes.NewReader(content), "utf-8")
if err != nil {
return nil, fmt.Errorf("open xls: %w", err)
}
// The upstream xls library panics for sparse rows when calling sheet.Row(i),
// so we rely on its safer bulk reader here instead of random row access.
rows := book.ReadAllCells(maxKnowledgeSpreadsheetRows)
cleaned := normalizeSpreadsheetRows(rows)
if len(cleaned) == 0 {
return nil, fmt.Errorf("xls contains no readable text")
}
title := "XLS 工作簿"
if book.NumSheets() == 1 {
if sheet := book.GetSheet(0); sheet != nil && strings.TrimSpace(sheet.Name) != "" {
title = strings.TrimSpace(sheet.Name)
}
}
return &knowledgeParsedContent{
Text: normalizeKnowledgeText(buildKnowledgeSheetPlainText(title, cleaned)),
Markdown: buildKnowledgeSheetMarkdown(title, cleaned),
}, nil
}
func normalizeSpreadsheetRows(rows [][]string) [][]string {
cleaned := make([][]string, 0, len(rows))
for _, row := range rows {
cells := make([]string, len(row))
copy(cells, row)
for index := range cells {
cells[index] = normalizeKnowledgeInlineText(cells[index])
}
lastNonEmpty := -1
for index, cell := range cells {
if cell != "" {
lastNonEmpty = index
}
}
if lastNonEmpty < 0 {
continue
}
cleaned = append(cleaned, cells[:lastNonEmpty+1])
}
return cleaned
}
func buildKnowledgeSheetPlainText(sheetName string, rows [][]string) string {
lines := make([]string, 0, len(rows)+1)
if strings.TrimSpace(sheetName) != "" {
lines = append(lines, strings.TrimSpace(sheetName))
}
for _, row := range rows {
lines = append(lines, strings.Join(row, " | "))
}
return strings.Join(lines, "\n")
}
func buildKnowledgeSheetMarkdown(sheetName string, rows [][]string) string {
if len(rows) == 0 {
return ""
}
width := 0
for _, row := range rows {
if len(row) > width {
width = len(row)
}
}
if width == 0 {
return ""
}
normalizedRows := make([][]string, 0, len(rows))
for _, row := range rows {
normalizedRows = append(normalizedRows, padKnowledgeRow(row, width))
}
var builder strings.Builder
if title := strings.TrimSpace(sheetName); title != "" {
builder.WriteString("## ")
builder.WriteString(title)
builder.WriteString("\n\n")
}
writeKnowledgeMarkdownTableRow(&builder, normalizedRows[0])
writeKnowledgeMarkdownTableSeparator(&builder, width)
for _, row := range normalizedRows[1:] {
writeKnowledgeMarkdownTableRow(&builder, row)
}
return strings.TrimSpace(builder.String())
}
func padKnowledgeRow(row []string, width int) []string {
result := make([]string, width)
copy(result, row)
return result
}
func writeKnowledgeMarkdownTableRow(builder *strings.Builder, row []string) {
builder.WriteString("|")
for _, cell := range row {
builder.WriteString(" ")
builder.WriteString(escapeKnowledgeMarkdownTableCell(cell))
builder.WriteString(" |")
}
builder.WriteString("\n")
}
func writeKnowledgeMarkdownTableSeparator(builder *strings.Builder, width int) {
builder.WriteString("|")
for index := 0; index < width; index++ {
builder.WriteString(" --- |")
}
builder.WriteString("\n")
}
func escapeKnowledgeMarkdownTableCell(cell string) string {
cell = strings.TrimSpace(cell)
cell = strings.ReplaceAll(cell, "\\", "\\\\")
cell = strings.ReplaceAll(cell, "|", "\\|")
cell = strings.ReplaceAll(cell, "\r", " ")
cell = strings.ReplaceAll(cell, "\n", " / ")
return cell
}
func extractPDFText(content []byte) (string, error) {
reader, err := pdf.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return "", fmt.Errorf("open pdf: %w", err)
}
var builder strings.Builder
totalPage := reader.NumPage()
for pageIndex := 1; pageIndex <= totalPage; pageIndex++ {
page := reader.Page(pageIndex)
if page.V.IsNull() {
continue
}
text, err := page.GetPlainText(nil)
if err != nil {
return "", fmt.Errorf("extract pdf text: %w", err)
}
if text != "" {
builder.WriteString(text)
builder.WriteString("\n")
}
}
result := normalizeKnowledgeText(builder.String())
if result == "" {
return "", fmt.Errorf("pdf contains no readable text")
}
return result, nil
}
func defaultKnowledgeTitle(fileName string) string {
base := filepath.Base(strings.TrimSpace(fileName))
if base == "" {
return ""
}
return strings.TrimSuffix(base, filepath.Ext(base))
}
func buildKnowledgeMarkdownFromText(title, text string) string {
text = normalizeKnowledgeText(text)
title = strings.TrimSpace(title)
if text == "" {
if title == "" {
return ""
}
return "# " + title
}
lines := strings.Split(text, "\n")
if title != "" && len(lines) > 0 && strings.EqualFold(strings.TrimSpace(lines[0]), title) {
lines = lines[1:]
}
paragraphs := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
paragraphs = append(paragraphs, line)
}
}
sections := make([]string, 0, 2)
if title != "" {
sections = append(sections, "# "+title)
}
if len(paragraphs) > 0 {
sections = append(sections, strings.Join(paragraphs, "\n\n"))
}
return strings.TrimSpace(strings.Join(sections, "\n\n"))
}
func extractKnowledgeMarkdownTitle(markdown string) string {
markdown = normalizeKnowledgeMarkdown(markdown)
if markdown == "" {
return ""
}
for _, line := range strings.Split(markdown, "\n") {
line = strings.TrimSpace(line)
if line == "" || !strings.HasPrefix(line, "#") {
continue
}
level := 0
for level < len(line) && line[level] == '#' {
level++
}
if level == len(line) || line[level] != ' ' {
continue
}
title := strings.Join(strings.Fields(strings.TrimSpace(line[level+1:])), " ")
title = strings.Trim(title, "# \t")
if title == "" || genericKnowledgeMarkdownTitleRegex.MatchString(title) {
continue
}
switch title {
case "摘要", "概述", "概要", "目录", "正文":
continue
}
runes := []rune(title)
if len(runes) > 200 {
title = string(runes[:200])
}
return title
}
return ""
}
func normalizeKnowledgeMarkdown(input string) string {
input = strings.ReplaceAll(input, "\r\n", "\n")
input = strings.ReplaceAll(input, "\u00a0", " ")
lines := strings.Split(input, "\n")
cleaned := make([]string, 0, len(lines))
lastBlank := false
for _, line := range lines {
line = strings.TrimRight(line, " \t")
if strings.TrimSpace(line) == "" {
if lastBlank {
continue
}
lastBlank = true
cleaned = append(cleaned, "")
continue
}
lastBlank = false
cleaned = append(cleaned, line)
}
return strings.TrimSpace(strings.Join(cleaned, "\n"))
}
func normalizeKnowledgeText(input string) string {
input = strings.ReplaceAll(input, "\u00a0", " ")
lines := strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
line = normalizeKnowledgeInlineText(line)
if line != "" {
cleaned = append(cleaned, line)
}
}
return strings.TrimSpace(strings.Join(cleaned, "\n"))
}
func normalizeKnowledgeInlineText(input string) string {
return whitespacePattern.ReplaceAllString(strings.TrimSpace(input), " ")
}