Files
ragflow/internal/handler/mindmap.go

292 lines
8.2 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package handler
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"ragflow/internal/common"
"ragflow/internal/entity"
modelModule "ragflow/internal/entity/models"
"ragflow/internal/service"
)
type mindMapRunConfig struct {
Question string
KbIDs common.StringSlice
SearchID string
SearchConfig map[string]interface{}
AuthUserID string
ModelTenantID string
ChunkSvc ChunkRetriever
LLM chatLLM
TenantSvc *service.TenantService
}
func runMindMap(config mindMapRunConfig) (mindMapNode, error) {
if config.ChunkSvc == nil {
return mindMapNode{}, fmt.Errorf("chunk service not configured")
}
if config.LLM == nil {
return mindMapNode{}, fmt.Errorf("LLM not configured")
}
modelTenantID := config.ModelTenantID
if modelTenantID == "" {
modelTenantID = config.AuthUserID
}
retrievalReq := mindMapRetrievalRequest(config.Question, config.KbIDs, config.SearchID, config.SearchConfig)
ranks, err := config.ChunkSvc.RetrievalTest(retrievalReq, config.AuthUserID)
if err != nil {
return mindMapNode{}, err
}
sections := mindMapSections(ranks)
if len(sections) == 0 {
return mindMapNode{ID: "root", Children: []mindMapNode{}}, nil
}
modelID, _ := config.SearchConfig["chat_id"].(string)
if modelID == "" && config.TenantSvc != nil {
defaultModel, err := config.TenantSvc.GetDefaultModelName(modelTenantID, entity.ModelTypeChat)
if err == nil {
modelID = defaultModel
}
}
response, err := config.LLM.Chat(modelTenantID, modelID, []modelModule.Message{{Role: "user", Content: mindMapPrompt(strings.Join(sections, "\n"))}, {Role: "user", Content: "Output:"}}, &modelModule.ChatConfig{})
if err != nil {
return mindMapNode{}, err
}
if response == nil || response.Answer == nil {
return mindMapNode{ID: "root", Children: []mindMapNode{}}, nil
}
return parseMindMapMarkdown(*response.Answer), nil
}
func searchConfigFromDetail(detail map[string]interface{}) map[string]interface{} {
if sc, ok := detail["search_config"].(map[string]interface{}); ok && sc != nil {
return sc
}
if sc, ok := detail["search_config"].(entity.JSONMap); ok && sc != nil {
return map[string]interface{}(sc)
}
return map[string]interface{}{}
}
func mindMapRetrievalRequest(question string, kbIDs common.StringSlice, searchID string, searchConfig map[string]interface{}) *service.RetrievalTestRequest {
page := 1
size := 12
topK := intFromConfig(searchConfig, "top_k", 1024)
similarityThreshold := floatFromConfig(searchConfig, "similarity_threshold", 0.2)
vectorSimilarityWeight := floatFromConfig(searchConfig, "vector_similarity_weight", 0.3)
req := &service.RetrievalTestRequest{
Datasets: kbIDs,
Question: question,
Page: &page,
Size: &size,
TopK: &topK,
SimilarityThreshold: &similarityThreshold,
VectorSimilarityWeight: &vectorSimilarityWeight,
DocIDs: stringSliceFromConfig(searchConfig, "doc_ids"),
Filter: mapFromConfig(searchConfig, "meta_data_filter"),
}
if searchID != "" {
req.SearchID = &searchID
}
if rerankID, _ := searchConfig["rerank_id"].(string); rerankID != "" {
req.RerankID = &rerankID
}
return req
}
func mindMapSections(ranks *service.RetrievalTestResponse) []string {
if ranks == nil {
return nil
}
sections := make([]string, 0, len(ranks.Chunks))
for _, chunk := range ranks.Chunks {
if content, ok := chunk["content_with_weight"].(string); ok && strings.TrimSpace(content) != "" {
sections = append(sections, content)
}
}
return sections
}
func mergeMindMapKbIDs(saved []string, requested common.StringSlice) common.StringSlice {
seen := map[string]bool{}
merged := make(common.StringSlice, 0, len(saved)+len(requested))
for _, id := range saved {
id = strings.TrimSpace(id)
if id != "" && !seen[id] {
seen[id] = true
merged = append(merged, id)
}
}
for _, id := range requested {
id = strings.TrimSpace(id)
if id != "" && !seen[id] {
seen[id] = true
merged = append(merged, id)
}
}
return merged
}
func intFromConfig(config map[string]interface{}, key string, fallback int) int {
switch v := config[key].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
if n, err := v.Int64(); err == nil {
return int(n)
}
}
return fallback
}
func floatFromConfig(config map[string]interface{}, key string, fallback float64) float64 {
switch v := config[key].(type) {
case float64:
return v
case float32:
return float64(v)
case int:
return float64(v)
case int64:
return float64(v)
case json.Number:
if n, err := v.Float64(); err == nil {
return n
}
}
return fallback
}
func stringSliceFromConfig(config map[string]interface{}, key string) []string {
switch v := config[key].(type) {
case []string:
return v
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
return nil
}
func mapFromConfig(config map[string]interface{}, key string) map[string]interface{} {
if m, ok := config[key].(map[string]interface{}); ok {
return m
}
if m, ok := config[key].(entity.JSONMap); ok {
return map[string]interface{}(m)
}
return nil
}
func mindMapPrompt(inputText string) string {
return `- Role: You're a talent text processor to summarize a piece of text into a mind map.
- Step of task:
1. Generate a title for user's 'TEXT'.
2. Classify the 'TEXT' into sections of a mind map.
3. If the subject matter is really complex, split them into sub-sections and sub-subsections.
4. Add a shot content summary of the bottom level section.
- Output requirement:
- Generate at least 4 levels.
- Always try to maximize the number of sub-sections.
- In language of 'Text'
- MUST IN FORMAT OF MARKDOWN
-TEXT-
` + inputText + "\n"
}
type mindMapNode struct {
ID string `json:"id"`
Children []mindMapNode `json:"children"`
}
var mindMapHeadingRe = regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
var mindMapListRe = regexp.MustCompile(`^(\s*)(?:[-*+]|\d+\.)\s+(.+)$`)
func parseMindMapMarkdown(text string) mindMapNode {
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
root := mindMapNode{ID: "root", Children: []mindMapNode{}}
stack := []*mindMapNode{&root}
inFence := false
listBaseLevel := 1
lastWasList := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inFence = !inFence
lastWasList = false
continue
}
if inFence || trimmed == "" {
lastWasList = false
continue
}
level := 0
title := ""
if m := mindMapHeadingRe.FindStringSubmatch(trimmed); len(m) == 3 {
level = len(m[1])
title = cleanMindMapText(m[2])
lastWasList = false
} else if m := mindMapListRe.FindStringSubmatch(line); len(m) == 3 {
rawLevel := len(m[1])/2 + 1
if !lastWasList {
listBaseLevel = len(stack)
}
level = listBaseLevel + rawLevel - 1
title = cleanMindMapText(m[2])
lastWasList = true
}
if title == "" {
lastWasList = false
continue
}
for len(stack) > level {
stack = stack[:len(stack)-1]
}
parent := stack[len(stack)-1]
parent.Children = append(parent.Children, mindMapNode{ID: title, Children: []mindMapNode{}})
stack = append(stack, &parent.Children[len(parent.Children)-1])
}
if len(root.Children) == 1 {
return root.Children[0]
}
return root
}
func cleanMindMapText(text string) string {
text = strings.TrimSpace(text)
text = strings.Trim(text, "`")
text = strings.Trim(text, "*_ ")
return strings.TrimSpace(text)
}