mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 14:15:42 +00:00
514 lines
14 KiB
Go
514 lines
14 KiB
Go
package stringutil
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// Bump whenever ChunkHTMLContent changes the text it emits for unchanged input; it feeds reindex fingerprints.
|
|
const ChunkerVersion = 3
|
|
|
|
var (
|
|
sentenceRegex = regexp.MustCompile(`[.!?]+[\s]+`)
|
|
headingInnerRegex = regexp.MustCompile(`(?is)<h[1-6][^>]*>(.*?)</h[1-6]>`)
|
|
|
|
// nonContentElements hold markup, not prose. HTML2Text only skips head, script and style, so
|
|
// svg, noscript and template have to be excluded here or their text reaches the index.
|
|
nonContentElements = map[string]bool{
|
|
"head": true, "script": true, "style": true, "noscript": true, "template": true, "svg": true,
|
|
}
|
|
|
|
blockElements = map[string]bool{
|
|
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
|
|
"p": true, "div": true, "section": true, "article": true, "aside": true,
|
|
"header": true, "footer": true, "main": true, "nav": true,
|
|
"ul": true, "ol": true, "li": true, "dl": true, "dt": true, "dd": true,
|
|
"table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true, "td": true, "th": true,
|
|
"form": true, "fieldset": true, "legend": true,
|
|
"blockquote": true, "pre": true, "code": true, "figure": true, "figcaption": true,
|
|
"address": true, "details": true, "summary": true, "hr": true,
|
|
}
|
|
)
|
|
|
|
type ChunkConfig struct {
|
|
MaxTokens int
|
|
MinTokens int
|
|
OverlapTokens int
|
|
TokenizerFunc func(string) int
|
|
PreserveBlocks []string
|
|
}
|
|
|
|
type htmlBoundary struct {
|
|
Type string
|
|
Content string
|
|
Priority int
|
|
Tokens int
|
|
}
|
|
|
|
// DefaultChunkConfig returns a ChunkConfig with sensible defaults for HTML chunking.
|
|
func DefaultChunkConfig() ChunkConfig {
|
|
return ChunkConfig{
|
|
MaxTokens: 2000,
|
|
MinTokens: 400,
|
|
OverlapTokens: 75,
|
|
TokenizerFunc: defaultTokenizer,
|
|
PreserveBlocks: []string{"pre", "code", "table"},
|
|
}
|
|
}
|
|
|
|
func (c *ChunkConfig) validate() error {
|
|
if c.MaxTokens <= c.MinTokens {
|
|
return fmt.Errorf("MaxTokens must be greater than MinTokens")
|
|
}
|
|
if c.OverlapTokens >= c.MinTokens {
|
|
return fmt.Errorf("OverlapTokens must be less than MinTokens")
|
|
}
|
|
if c.TokenizerFunc == nil {
|
|
c.TokenizerFunc = defaultTokenizer
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ChunkHTMLContent splits HTML into structure-aware chunks for embedding, prepending the title and section heading to each chunk's text.
|
|
func ChunkHTMLContent(title, htmlContent string, config ...ChunkConfig) ([]string, error) {
|
|
cfg := DefaultChunkConfig()
|
|
if len(config) > 0 {
|
|
cfg = config[0]
|
|
}
|
|
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if strings.TrimSpace(htmlContent) == "" {
|
|
return []string{buildEmbeddingText(title, "", "")}, nil
|
|
}
|
|
|
|
htmlContent = prepareHTMLForEmbedding(htmlContent)
|
|
|
|
boundaries, err := parseHTMLBoundaries(htmlContent, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse HTML: %w", err)
|
|
}
|
|
|
|
chunks := createChunks(boundaries, cfg)
|
|
result := make([]string, len(chunks))
|
|
|
|
var lastHeading string
|
|
for i, chunk := range chunks {
|
|
if h := extractLeadingHeading(chunk.Content); h != "" {
|
|
lastHeading = h
|
|
}
|
|
result[i] = buildEmbeddingText(title, lastHeading, HTML2Text(chunk.Content))
|
|
}
|
|
|
|
// Markup with no text at all still needs a chunk; embedding nothing is indistinguishable from success.
|
|
if len(result) == 0 {
|
|
return []string{buildEmbeddingText(title, "", "")}, nil
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// defaultTokenizer estimates token count with a conservative rune-based ratio.
|
|
func defaultTokenizer(text string) int {
|
|
return int(float64(utf8.RuneCountInString(text)) * 0.4)
|
|
}
|
|
|
|
func isBlockElement(tag string) bool {
|
|
return blockElements[tag]
|
|
}
|
|
|
|
func parseHTMLBoundaries(htmlContent string, cfg ChunkConfig) ([]htmlBoundary, error) {
|
|
doc, err := html.Parse(strings.NewReader(htmlContent))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var boundaries []htmlBoundary
|
|
|
|
var extract func(*html.Node)
|
|
extract = func(n *html.Node) {
|
|
switch n.Type {
|
|
case html.TextNode:
|
|
// Text outside any block element has no boundary of its own and would drop out of the index.
|
|
text := strings.TrimSpace(n.Data)
|
|
if text == "" {
|
|
return
|
|
}
|
|
// Unwrapped, so mergeBoundaries reassembles a run broken up by inline tags into one sentence.
|
|
boundaries = append(boundaries, htmlBoundary{
|
|
Type: "p",
|
|
Content: html.EscapeString(text) + " ",
|
|
Priority: getPriority("p"),
|
|
Tokens: cfg.TokenizerFunc(text),
|
|
})
|
|
return
|
|
case html.ElementNode:
|
|
tag := strings.ToLower(n.Data)
|
|
if nonContentElements[tag] {
|
|
return
|
|
}
|
|
|
|
// Rendering a non-block wrapper would redo its whole subtree once per nesting level.
|
|
if isBlockElement(tag) {
|
|
var content strings.Builder
|
|
_ = html.Render(&content, n)
|
|
contentStr := content.String()
|
|
|
|
cleanText := HTML2Text(contentStr)
|
|
if strings.TrimSpace(cleanText) == "" {
|
|
return
|
|
}
|
|
|
|
tokens := cfg.TokenizerFunc(cleanText)
|
|
// An oversized container taken as one atomic boundary would be truncated at
|
|
// MaxTokens, silently dropping the rest; descend into its block children instead.
|
|
if tokens <= cfg.MaxTokens || isPreservedBlock(tag, cfg.PreserveBlocks) || !splittableIntoBlocks(n) {
|
|
boundaries = append(boundaries, htmlBoundary{
|
|
Type: tag,
|
|
Content: contentStr,
|
|
Priority: getPriority(tag),
|
|
Tokens: tokens,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
extract(c)
|
|
}
|
|
}
|
|
|
|
extract(doc)
|
|
|
|
return mergeBoundaries(boundaries, cfg), nil
|
|
}
|
|
|
|
// getPriority ranks tags for chunking; lower is higher priority.
|
|
func getPriority(tag string) int {
|
|
switch tag {
|
|
case "h1", "h2":
|
|
return 1
|
|
case "h3", "h4", "h5", "h6", "pre", "code":
|
|
return 2
|
|
case "p", "table", "ul", "ol", "blockquote":
|
|
return 3
|
|
case "div", "section", "article", "figure":
|
|
return 4
|
|
default:
|
|
return 5
|
|
}
|
|
}
|
|
|
|
func isPreservedBlock(blockType string, preserveBlocks []string) bool {
|
|
return slices.Contains(preserveBlocks, blockType)
|
|
}
|
|
|
|
// splittableIntoBlocks reports whether all of a node's visible content lives inside block-element
|
|
// children, so splitting the node into its children loses nothing.
|
|
func splittableIntoBlocks(n *html.Node) bool {
|
|
hasBlock := false
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
switch c.Type {
|
|
case html.TextNode:
|
|
if strings.TrimSpace(c.Data) != "" {
|
|
return false
|
|
}
|
|
case html.ElementNode:
|
|
if isBlockElement(strings.ToLower(c.Data)) {
|
|
hasBlock = true
|
|
continue
|
|
}
|
|
var buf strings.Builder
|
|
_ = html.Render(&buf, c)
|
|
if strings.TrimSpace(HTML2Text(buf.String())) != "" {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return hasBlock
|
|
}
|
|
|
|
func mergeBoundaries(boundaries []htmlBoundary, cfg ChunkConfig) []htmlBoundary {
|
|
if len(boundaries) == 0 {
|
|
return boundaries
|
|
}
|
|
|
|
var merged []htmlBoundary
|
|
current := boundaries[0]
|
|
|
|
for i := 1; i < len(boundaries); i++ {
|
|
next := boundaries[i]
|
|
|
|
if next.Priority == 1 {
|
|
merged = append(merged, current)
|
|
current = next
|
|
continue
|
|
}
|
|
|
|
if current.Priority == 1 && current.Tokens >= cfg.MinTokens {
|
|
merged = append(merged, current)
|
|
current = next
|
|
continue
|
|
}
|
|
|
|
if isPreservedBlock(current.Type, cfg.PreserveBlocks) || isPreservedBlock(next.Type, cfg.PreserveBlocks) {
|
|
merged = append(merged, current)
|
|
current = next
|
|
continue
|
|
}
|
|
|
|
combinedTokens := current.Tokens + next.Tokens
|
|
shouldMerge := false
|
|
|
|
if combinedTokens < cfg.MinTokens {
|
|
shouldMerge = true
|
|
} else if current.Priority >= 3 && next.Priority >= 3 && combinedTokens < cfg.MaxTokens {
|
|
shouldMerge = true
|
|
}
|
|
|
|
if shouldMerge {
|
|
current.Content += next.Content
|
|
current.Tokens = combinedTokens
|
|
current.Priority = min(current.Priority, next.Priority)
|
|
} else {
|
|
merged = append(merged, current)
|
|
current = next
|
|
}
|
|
}
|
|
|
|
merged = append(merged, current)
|
|
return merged
|
|
}
|
|
|
|
// splitOversizedBoundary breaks an atomic boundary into MaxTokens-sized pieces on sentence boundaries.
|
|
func splitOversizedBoundary(boundary htmlBoundary, cfg ChunkConfig) []htmlBoundary {
|
|
var out []htmlBoundary
|
|
// Only Content is read downstream; setting Tokens would be a second tokenizer pass over the whole block.
|
|
for _, piece := range chunkPlainText(HTML2Text(boundary.Content), boundary.Tokens, cfg) {
|
|
piece = strings.TrimSpace(piece)
|
|
if piece == "" {
|
|
continue
|
|
}
|
|
out = append(out, htmlBoundary{Content: "<p>" + html.EscapeString(piece) + "</p>"})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func createChunks(boundaries []htmlBoundary, cfg ChunkConfig) []htmlBoundary {
|
|
if len(boundaries) == 0 {
|
|
return boundaries
|
|
}
|
|
|
|
var chunks []htmlBoundary
|
|
var currentChunk htmlBoundary
|
|
currentChunk.Priority = 10
|
|
|
|
for _, boundary := range boundaries {
|
|
// Split here, not at the boundary source, so an overlap prefix can't push a piece back over the limit.
|
|
if boundary.Tokens > cfg.MaxTokens {
|
|
if currentChunk.Content != "" {
|
|
chunks = append(chunks, currentChunk)
|
|
currentChunk = htmlBoundary{Priority: 10}
|
|
}
|
|
chunks = append(chunks, splitOversizedBoundary(boundary, cfg)...)
|
|
continue
|
|
}
|
|
|
|
shouldStartNewChunk := false
|
|
|
|
if boundary.Priority == 1 && currentChunk.Tokens >= cfg.MinTokens {
|
|
shouldStartNewChunk = true
|
|
}
|
|
|
|
if currentChunk.Tokens+boundary.Tokens > cfg.MaxTokens {
|
|
if currentChunk.Content != "" {
|
|
shouldStartNewChunk = true
|
|
}
|
|
}
|
|
|
|
if shouldStartNewChunk && currentChunk.Content != "" {
|
|
chunks = append(chunks, currentChunk)
|
|
|
|
var overlapContent string
|
|
if !isPreservedBlock(boundary.Type, cfg.PreserveBlocks) {
|
|
overlapContent = extractOverlap(currentChunk.Content, cfg)
|
|
}
|
|
|
|
currentChunk = htmlBoundary{
|
|
Content: overlapContent,
|
|
Tokens: cfg.TokenizerFunc(HTML2Text(overlapContent)),
|
|
Priority: 10,
|
|
}
|
|
}
|
|
|
|
currentChunk.Content += boundary.Content
|
|
currentChunk.Tokens += boundary.Tokens
|
|
|
|
if boundary.Priority < currentChunk.Priority {
|
|
currentChunk.Priority = boundary.Priority
|
|
}
|
|
}
|
|
|
|
if currentChunk.Content != "" {
|
|
chunks = append(chunks, currentChunk)
|
|
}
|
|
|
|
return chunks
|
|
}
|
|
|
|
// extractOverlap carries trailing whole sentences into the next chunk for context continuity.
|
|
func extractOverlap(content string, cfg ChunkConfig) string {
|
|
cleanText := HTML2Text(content)
|
|
sentences := splitSentences(cleanText)
|
|
|
|
if len(sentences) <= 1 {
|
|
return ""
|
|
}
|
|
|
|
var overlap []string
|
|
tokens := 0
|
|
for i := len(sentences) - 1; i >= 0 && tokens < cfg.OverlapTokens; i-- {
|
|
sentence := strings.TrimSpace(sentences[i])
|
|
if sentence == "" {
|
|
continue
|
|
}
|
|
sentTokens := cfg.TokenizerFunc(sentence)
|
|
if tokens+sentTokens <= cfg.OverlapTokens {
|
|
overlap = append([]string{sentence}, overlap...)
|
|
tokens += sentTokens
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(overlap) == 0 {
|
|
return ""
|
|
}
|
|
|
|
return "<p>" + html.EscapeString(strings.Join(overlap, " ")) + "</p>\n"
|
|
}
|
|
|
|
// extractLeadingHeading returns the plain text of the first heading in the chunk HTML, if any.
|
|
func extractLeadingHeading(htmlContent string) string {
|
|
m := headingInnerRegex.FindStringSubmatch(htmlContent)
|
|
if len(m) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(HTML2Text(m[1]))
|
|
}
|
|
|
|
// buildEmbeddingText prepends the title and section heading (when present) to the chunk text.
|
|
func buildEmbeddingText(title, heading, cleanText string) string {
|
|
title = strings.TrimSpace(title)
|
|
heading = strings.TrimSpace(heading)
|
|
cleanText = strings.TrimSpace(cleanText)
|
|
|
|
if title == "" && heading == "" {
|
|
return cleanText
|
|
}
|
|
if cleanText == "" {
|
|
return title
|
|
}
|
|
|
|
var b strings.Builder
|
|
if title != "" {
|
|
fmt.Fprintf(&b, "Title: %s\n", title)
|
|
}
|
|
if heading != "" && !strings.EqualFold(heading, title) {
|
|
fmt.Fprintf(&b, "Section: %s\n", heading)
|
|
}
|
|
fmt.Fprintf(&b, "Content: %s", cleanText)
|
|
return b.String()
|
|
}
|
|
|
|
// chunkPlainText packs text into <=MaxTokens pieces on sentence boundaries, hard-splitting any single sentence over the cap; tokens is the caller's already-measured count for text.
|
|
func chunkPlainText(text string, tokens int, cfg ChunkConfig) []string {
|
|
if tokens <= cfg.MaxTokens {
|
|
return []string{text}
|
|
}
|
|
|
|
var chunks []string
|
|
var cur strings.Builder
|
|
curTokens := 0
|
|
flush := func() {
|
|
if cur.Len() > 0 {
|
|
chunks = append(chunks, strings.TrimSpace(cur.String()))
|
|
cur.Reset()
|
|
curTokens = 0
|
|
}
|
|
}
|
|
|
|
for _, s := range splitSentences(text) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
st := cfg.TokenizerFunc(s)
|
|
if st > cfg.MaxTokens {
|
|
flush()
|
|
chunks = append(chunks, hardSplit(s, cfg)...)
|
|
continue
|
|
}
|
|
if curTokens+st > cfg.MaxTokens {
|
|
flush()
|
|
}
|
|
if cur.Len() > 0 {
|
|
cur.WriteString(" ")
|
|
}
|
|
cur.WriteString(s)
|
|
curTokens += st
|
|
}
|
|
flush()
|
|
return chunks
|
|
}
|
|
|
|
// hardSplit cuts s into consecutive pieces each within MaxTokens, taking the largest fitting rune prefix per piece.
|
|
func hardSplit(s string, cfg ChunkConfig) []string {
|
|
runes := []rune(s)
|
|
var out []string
|
|
for len(runes) > 0 {
|
|
// Probing upward first keeps tokenizer calls near the piece size instead of the whole remaining string.
|
|
hi := 2
|
|
for hi < len(runes) && cfg.TokenizerFunc(string(runes[:hi])) <= cfg.MaxTokens {
|
|
hi *= 2
|
|
}
|
|
hi = min(hi, len(runes))
|
|
lo, best := 1, 1
|
|
for lo <= hi {
|
|
mid := (lo + hi) / 2
|
|
if cfg.TokenizerFunc(string(runes[:mid])) <= cfg.MaxTokens {
|
|
best = mid
|
|
lo = mid + 1
|
|
} else {
|
|
hi = mid - 1
|
|
}
|
|
}
|
|
out = append(out, string(runes[:best]))
|
|
runes = runes[best:]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// splitSentences splits on sentence boundaries, keeping each sentence's terminating punctuation.
|
|
func splitSentences(text string) []string {
|
|
seps := sentenceRegex.FindAllStringIndex(text, -1)
|
|
if len(seps) == 0 {
|
|
return []string{text}
|
|
}
|
|
out := make([]string, 0, len(seps)+1)
|
|
prev := 0
|
|
for _, sep := range seps {
|
|
out = append(out, text[prev:sep[0]]+strings.TrimSpace(text[sep[0]:sep[1]]))
|
|
prev = sep[1]
|
|
}
|
|
return append(out, text[prev:])
|
|
}
|