mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-08 20:34:48 +08:00
### What problem does this PR solve? - Tools management - Pregel engine wrapper for better usage - UT race - Coding style ### Type of change - [x] Refactoring
278 lines
6.7 KiB
Go
278 lines
6.7 KiB
Go
package core
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// PromptBuilder builds a system prompt with dynamic context, instruction files,
|
|
// and character budgets. It mirrors claw-code's SystemPromptBuilder.
|
|
type PromptBuilder struct {
|
|
buf strings.Builder
|
|
}
|
|
|
|
// NewPromptBuilder creates a new PromptBuilder.
|
|
func NewPromptBuilder() *PromptBuilder {
|
|
return &PromptBuilder{}
|
|
}
|
|
|
|
// PromptBudget defines character limits for prompt sections.
|
|
type PromptBudget struct {
|
|
PerFile int // Max chars per instruction file (default: 4000)
|
|
TotalFiles int // Max total chars from all instruction files (default: 12000)
|
|
GitDiff int // Max chars for git diff (default: 50000)
|
|
MaxSections int // Max number of sections (default: 20)
|
|
}
|
|
|
|
func (b *PromptBudget) defaults() {
|
|
if b.PerFile <= 0 {
|
|
b.PerFile = 4000
|
|
}
|
|
if b.TotalFiles <= 0 {
|
|
b.TotalFiles = 12000
|
|
}
|
|
if b.GitDiff <= 0 {
|
|
b.GitDiff = 50000
|
|
}
|
|
if b.MaxSections <= 0 {
|
|
b.MaxSections = 20
|
|
}
|
|
}
|
|
|
|
// Build constructs the final prompt string.
|
|
func (pb *PromptBuilder) Build(parts []PromptSection, budget *PromptBudget) string {
|
|
budget.defaults()
|
|
pb.buf.Reset()
|
|
|
|
for i, p := range parts {
|
|
if i >= budget.MaxSections {
|
|
break
|
|
}
|
|
text := p.Content
|
|
if p.TruncateTo > 0 && len(text) > p.TruncateTo {
|
|
text = text[:p.TruncateTo] + "\n...[truncated]"
|
|
}
|
|
if p.PrependNewline && pb.buf.Len() > 0 {
|
|
pb.buf.WriteString("\n")
|
|
}
|
|
pb.buf.WriteString(text)
|
|
if p.AppendNewline {
|
|
pb.buf.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return pb.buf.String()
|
|
}
|
|
|
|
// PromptSection is a named section of the prompt.
|
|
type PromptSection struct {
|
|
Name string
|
|
Content string
|
|
TruncateTo int // Max chars for this section (0 = no limit)
|
|
AppendNewline bool
|
|
PrependNewline bool
|
|
}
|
|
|
|
// ---- Instruction file discovery ----
|
|
|
|
// InstructionFile represents a discovered instruction file.
|
|
type InstructionFile struct {
|
|
Path string
|
|
Content string
|
|
}
|
|
|
|
// DiscoverInstructionFiles finds instruction files by walking up from startDir
|
|
// to the git root. Searches for: CLAUDE.md, CLAW.md, AGENTS.md, and .claw/rules/*.md
|
|
func DiscoverInstructionFiles(startDir string) ([]InstructionFile, error) {
|
|
root := findGitRoot(startDir)
|
|
if root == "" {
|
|
root = startDir
|
|
}
|
|
|
|
var files []InstructionFile
|
|
candidates := []string{
|
|
"CLAUDE.md", "CLAW.md", "AGENTS.md",
|
|
".claw/CLAUDE.md", ".claw/instructions.md",
|
|
}
|
|
|
|
for _, name := range candidates {
|
|
path := filepath.Join(root, name)
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, InstructionFile{Path: path, Content: string(content)})
|
|
}
|
|
|
|
// Load rules directory
|
|
rulesDir := filepath.Join(root, ".claw", "rules")
|
|
if entries, err := os.ReadDir(rulesDir); err == nil {
|
|
for _, e := range entries {
|
|
if !e.IsDir() && (strings.HasSuffix(e.Name(), ".md") || strings.HasSuffix(e.Name(), ".txt")) {
|
|
path := filepath.Join(rulesDir, e.Name())
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, InstructionFile{Path: path, Content: string(content)})
|
|
}
|
|
}
|
|
}
|
|
|
|
return files, nil
|
|
}
|
|
|
|
// InstructionFileSections converts instruction files to prompt sections with budget.
|
|
func InstructionFileSections(files []InstructionFile, budget *PromptBudget) []PromptSection {
|
|
budget.defaults()
|
|
var sections []PromptSection
|
|
remaining := budget.TotalFiles
|
|
|
|
for _, f := range files {
|
|
if remaining <= 0 {
|
|
break
|
|
}
|
|
content := f.Content
|
|
if len(content) > budget.PerFile {
|
|
content = content[:budget.PerFile] + "\n...[truncated]"
|
|
}
|
|
if len(content) > remaining {
|
|
content = content[:remaining] + "\n...[truncated]"
|
|
}
|
|
remaining -= len(content)
|
|
|
|
name := filepath.Base(f.Path)
|
|
sections = append(sections, PromptSection{
|
|
Name: "Instruction: " + name,
|
|
Content: content,
|
|
AppendNewline: true,
|
|
PrependNewline: true,
|
|
})
|
|
}
|
|
return sections
|
|
}
|
|
|
|
// ---- Dynamic context sections ----
|
|
|
|
// EnvSection creates a prompt section with OS, date, and CWD information.
|
|
func EnvSection() PromptSection {
|
|
hostname, _ := os.Hostname()
|
|
cwd, _ := os.Getwd()
|
|
now := time.Now().Format(time.RFC3339)
|
|
|
|
return PromptSection{
|
|
Name: "Environment",
|
|
Content: fmt.Sprintf("Date: %s\nOS: %s/%s\nHost: %s\nCWD: %s",
|
|
now, runtime.GOOS, runtime.GOARCH, hostname, cwd),
|
|
AppendNewline: true,
|
|
PrependNewline: true,
|
|
}
|
|
}
|
|
|
|
// GitDiffSection creates a prompt section with git diff output.
|
|
func GitDiffSection(budgetChars int) PromptSection {
|
|
diff, _ := execGitDiff()
|
|
if diff == "" {
|
|
return PromptSection{Name: "GitDiff"}
|
|
}
|
|
if budgetChars > 0 && len(diff) > budgetChars {
|
|
diff = diff[:budgetChars] + "\n...[diff truncated]"
|
|
}
|
|
return PromptSection{
|
|
Name: "Git Diff",
|
|
Content: fmt.Sprintf("Working tree changes (git diff):\n%s", diff),
|
|
AppendNewline: true,
|
|
}
|
|
}
|
|
|
|
func execGitDiff() (string, error) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
root := findGitRoot(cwd)
|
|
if root == "" {
|
|
return "", fmt.Errorf("not a git repository")
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(root, ".git", "HEAD"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
ref := strings.TrimSpace(string(data))
|
|
return fmt.Sprintf("HEAD: %s", ref), nil
|
|
}
|
|
|
|
// findGitRoot walks up from dir to find the .git directory.
|
|
func findGitRoot(dir string) string {
|
|
dir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return ""
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// GroupSectionsWithBudget appends sections respecting a total character budget.
|
|
func GroupSectionsWithBudget(sections []PromptSection, budget int) []PromptSection {
|
|
var result []PromptSection
|
|
remaining := budget
|
|
for _, s := range sections {
|
|
if s.Content == "" {
|
|
continue
|
|
}
|
|
if remaining <= 0 {
|
|
break
|
|
}
|
|
if len(s.Content) > remaining {
|
|
s.Content = s.Content[:remaining] + "\n...[truncated]"
|
|
s.TruncateTo = len(s.Content)
|
|
}
|
|
remaining -= len(s.Content)
|
|
result = append(result, s)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// DeduplicateSections removes sections with duplicate content (by exact match).
|
|
func DeduplicateSections(sections []PromptSection) []PromptSection {
|
|
seen := make(map[string]bool)
|
|
var result []PromptSection
|
|
for _, s := range sections {
|
|
key := strings.TrimSpace(s.Content)
|
|
if key == "" || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
result = append(result, s)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SortSections puts core sections first, then by name.
|
|
func SortSections(sections []PromptSection) {
|
|
sort.SliceStable(sections, func(i, j int) bool {
|
|
core := map[string]int{
|
|
"Environment": 0, "Capabilities": 1, "Instructions": 2,
|
|
}
|
|
pi := core[sections[i].Name]
|
|
pj := core[sections[j].Name]
|
|
if pi != pj {
|
|
return pi < pj
|
|
}
|
|
return sections[i].Name < sections[j].Name
|
|
})
|
|
}
|