238 lines
6.9 KiB
Go
238 lines
6.9 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// 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 helpers
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// Lightweight local retrieval for the Q&A scenario: --knowledge-dir loads
|
|
// .md/.txt files, chunks them, and prepends the best-matching chunks to each
|
|
// forwarded question. The agent CLI itself keeps running from the clean
|
|
// scratch dir — retrieval injects knowledge without the workdir-scan slowdown
|
|
// that would miss DingTalk's AI-assistant reply window (the connector's core
|
|
// latency constraint, see execForwarder.forward).
|
|
const (
|
|
knowledgeTopK = 3
|
|
knowledgeMaxChunkRunes = 1200
|
|
knowledgeMaxPromptRunes = 3000
|
|
knowledgeMaxFileBytes = 1 << 20
|
|
)
|
|
|
|
type knowledgeChunk struct {
|
|
source string
|
|
text string
|
|
terms map[string]int
|
|
}
|
|
|
|
type knowledgeBase struct {
|
|
chunks []knowledgeChunk
|
|
}
|
|
|
|
// loadKnowledgeBase walks dir for .md/.txt files (hidden dirs skipped) and
|
|
// indexes them into scored-retrieval chunks.
|
|
// knowledgeTextExts is the set of plain-text extensions the retriever indexes.
|
|
var knowledgeTextExts = []string{".md", ".markdown", ".mdx", ".txt", ".text"}
|
|
|
|
var knowledgeReadFile = os.ReadFile
|
|
|
|
// isKnowledgeTextExt reports whether ext is an indexable plain-text extension.
|
|
func isKnowledgeTextExt(ext string) bool {
|
|
ext = strings.ToLower(ext)
|
|
for _, e := range knowledgeTextExts {
|
|
if ext == e {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func loadKnowledgeBase(dir string) (*knowledgeBase, error) {
|
|
kb := &knowledgeBase{}
|
|
root := filepath.Clean(dir)
|
|
indexed, skipped := 0, 0
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() {
|
|
if path != root && strings.HasPrefix(d.Name(), ".") {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !isKnowledgeTextExt(filepath.Ext(path)) {
|
|
skipped++
|
|
return nil
|
|
}
|
|
if info, ierr := d.Info(); ierr == nil && info.Size() > knowledgeMaxFileBytes {
|
|
fmt.Fprintf(os.Stderr, "[connect][knowledge] 跳过超大文件 %s\n", path)
|
|
return nil
|
|
}
|
|
raw, rerr := knowledgeReadFile(path)
|
|
if rerr != nil {
|
|
return rerr
|
|
}
|
|
rel, _ := filepath.Rel(root, path)
|
|
for _, chunk := range splitKnowledgeChunks(string(raw)) {
|
|
kb.chunks = append(kb.chunks, knowledgeChunk{source: rel, text: chunk, terms: knowledgeTerms(chunk)})
|
|
}
|
|
indexed++
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Tell the operator when files were skipped — a knowledge dir full of .pdf /
|
|
// .docx / .json indexes nothing, which otherwise looks like "knowledge has no
|
|
// effect" (issue #32). Only the .md/.txt text family is indexed.
|
|
if skipped > 0 {
|
|
fmt.Fprintf(os.Stderr, "[connect][knowledge] 已索引 %d 个文本文件,跳过 %d 个非文本文件(仅索引 %s)\n",
|
|
indexed, skipped, strings.Join(knowledgeTextExts, "/"))
|
|
}
|
|
if len(kb.chunks) == 0 {
|
|
return nil, fmt.Errorf("知识目录 %s 下没有可用的文本内容(仅索引 %s;pdf/docx/json 等不会被索引)", dir, strings.Join(knowledgeTextExts, "/"))
|
|
}
|
|
return kb, nil
|
|
}
|
|
|
|
// splitKnowledgeChunks cuts a document on markdown headings, merging lines up
|
|
// to knowledgeMaxChunkRunes so a chunk carries enough context to answer with.
|
|
func splitKnowledgeChunks(doc string) []string {
|
|
var chunks []string
|
|
var cur []string
|
|
curLen := 0
|
|
flush := func() {
|
|
if s := strings.TrimSpace(strings.Join(cur, "\n")); s != "" {
|
|
chunks = append(chunks, s)
|
|
}
|
|
cur = cur[:0]
|
|
curLen = 0
|
|
}
|
|
for _, line := range strings.Split(doc, "\n") {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "#") && curLen > 0 {
|
|
flush()
|
|
}
|
|
cur = append(cur, line)
|
|
curLen += len([]rune(line))
|
|
if curLen >= knowledgeMaxChunkRunes {
|
|
flush()
|
|
}
|
|
}
|
|
flush()
|
|
return chunks
|
|
}
|
|
|
|
// knowledgeTerms tokenizes for matching: lowercase latin/digit words (len>=2)
|
|
// plus CJK bigrams — CJK has no word boundaries and bigrams are the standard
|
|
// cheap approximation.
|
|
func knowledgeTerms(s string) map[string]int {
|
|
terms := map[string]int{}
|
|
var word []rune
|
|
var prevCJK rune
|
|
flushWord := func() {
|
|
if len(word) >= 2 {
|
|
terms[strings.ToLower(string(word))]++
|
|
}
|
|
word = word[:0]
|
|
}
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9':
|
|
word = append(word, r)
|
|
prevCJK = 0
|
|
case unicode.Is(unicode.Han, r):
|
|
flushWord()
|
|
if prevCJK != 0 {
|
|
terms[string([]rune{prevCJK, r})]++
|
|
}
|
|
prevCJK = r
|
|
default:
|
|
flushWord()
|
|
prevCJK = 0
|
|
}
|
|
}
|
|
flushWord()
|
|
return terms
|
|
}
|
|
|
|
// augment returns the prompt to forward: the original question, or the
|
|
// question prefixed with the top-k matching knowledge chunks. No match means
|
|
// no prefix — the agent answers from its own knowledge as before.
|
|
func (kb *knowledgeBase) augment(question string) string {
|
|
if kb == nil || len(kb.chunks) == 0 {
|
|
return question
|
|
}
|
|
qTerms := knowledgeTerms(question)
|
|
if len(qTerms) == 0 {
|
|
return question
|
|
}
|
|
type scored struct {
|
|
idx int
|
|
hits int // distinct query terms present
|
|
occ int // total occurrences, tie-break
|
|
}
|
|
var matches []scored
|
|
for i, c := range kb.chunks {
|
|
hits, occ := 0, 0
|
|
for t := range qTerms {
|
|
if n := c.terms[t]; n > 0 {
|
|
hits++
|
|
occ += n
|
|
}
|
|
}
|
|
if hits > 0 {
|
|
matches = append(matches, scored{i, hits, occ})
|
|
}
|
|
}
|
|
if len(matches) == 0 {
|
|
// Observability for "knowledge has no effect" (issue #32): the KB loaded
|
|
// but nothing in it overlapped this question, so no context was injected.
|
|
fmt.Fprintf(os.Stderr, "[connect][knowledge] 本条未命中知识库(%d 个片段中无相关内容)\n", len(kb.chunks))
|
|
return question
|
|
}
|
|
sort.Slice(matches, func(a, b int) bool {
|
|
if matches[a].hits != matches[b].hits {
|
|
return matches[a].hits > matches[b].hits
|
|
}
|
|
return matches[a].occ > matches[b].occ
|
|
})
|
|
var b strings.Builder
|
|
b.WriteString("以下是本地知识库中可能相关的资料,请优先依据它们回答;与问题无关时忽略:\n")
|
|
used := 0
|
|
for k := 0; k < len(matches) && k < knowledgeTopK; k++ {
|
|
c := kb.chunks[matches[k].idx]
|
|
text := truncateRunes(c.text, knowledgeMaxChunkRunes)
|
|
used += len([]rune(text))
|
|
if k > 0 && used > knowledgeMaxPromptRunes {
|
|
break
|
|
}
|
|
fmt.Fprintf(&b, "\n【资料%d · %s】\n%s\n", k+1, c.source, text)
|
|
}
|
|
b.WriteString("\n用户问题:")
|
|
b.WriteString(question)
|
|
injected := len(matches)
|
|
if injected > knowledgeTopK {
|
|
injected = knowledgeTopK
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[connect][knowledge] 本条命中并注入 %d 个知识片段\n", injected)
|
|
return b.String()
|
|
}
|