mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
feat: output channel redesign - per-channel output gates, LLM chain events, SDKConfig
- Output channels generate per-channel tools: output_send__{name} (type=output) + output_send__{name}_help
- content is JSON string transparently passed to plugin handler for routing
- EventAgentLLMChain: full LLM response forwarded after each turn for webui/logs
- sdk.New refactored to SDKConfig struct (no more 13 positional args)
- RegisterOutputChannel adds desc param for JSON format documentation
- channelDevice simplified (no Tools method), desc field added
- Child agent permission updated for output_send__ prefix
- System prompt: output gates, multi-call, long messages split
- WebUI: subscribes to EventAgentLLMChain in SSE, no output channel
- Tests updated for new naming convention
This commit is contained in:
118
internal/memory/cut.go
Normal file
118
internal/memory/cut.go
Normal file
@ -0,0 +1,118 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/yanyiwu/gojieba"
|
||||
)
|
||||
|
||||
var (
|
||||
jiebaOnce sync.Once
|
||||
jiebaInst *gojieba.Jieba
|
||||
)
|
||||
|
||||
func GetJieba() *gojieba.Jieba {
|
||||
jiebaOnce.Do(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[jieba] init panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
d := jiebaDictDir()
|
||||
if d == "" {
|
||||
log.Printf("[jieba] no dictionary directory found, jieba disabled")
|
||||
return
|
||||
}
|
||||
jiebaInst = gojieba.NewJieba(
|
||||
filepath.Join(d, "jieba.dict.utf8"),
|
||||
filepath.Join(d, "hmm_model.utf8"),
|
||||
filepath.Join(d, "user.dict.utf8"),
|
||||
filepath.Join(d, "idf.utf8"),
|
||||
filepath.Join(d, "stop_words.utf8"),
|
||||
)
|
||||
})
|
||||
return jiebaInst
|
||||
}
|
||||
|
||||
func jiebaDictDir() string {
|
||||
candidates := []string{
|
||||
os.Getenv("GOMODCACHE"),
|
||||
os.Getenv("GOPATH"),
|
||||
filepath.Join(os.Getenv("HOME"), "go"),
|
||||
"/root/go",
|
||||
"/go",
|
||||
"/home/program/go",
|
||||
}
|
||||
for _, base := range candidates {
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
d := filepath.Join(base, "pkg", "mod", "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict")
|
||||
if info, err := os.Stat(d); err == nil && info.IsDir() {
|
||||
return d
|
||||
}
|
||||
// also try without "pkg/mod" (in case GOPATH is already the mod cache)
|
||||
d2 := filepath.Join(base, "github.com", "yanyiwu", "gojieba@v1.4.7", "deps", "cppjieba", "dict")
|
||||
if info, err := os.Stat(d2); err == nil && info.IsDir() {
|
||||
return d2
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var stopWords = map[string]bool{
|
||||
"的": true, "了": true, "是": true, "在": true, "有": true,
|
||||
"和": true, "就": true, "不": true, "人": true, "都": true,
|
||||
"一": true, "一个": true, "上": true, "也": true, "很": true,
|
||||
"到": true, "说": true, "要": true, "去": true, "你": true,
|
||||
"会": true, "着": true, "没有": true, "看": true, "好": true,
|
||||
"自己": true, "这": true, "他": true, "她": true, "它": true,
|
||||
"我": true, "我们": true, "你们": true, "他们": true,
|
||||
"吗": true, "吧": true, "啊": true,
|
||||
"嗯": true, "哦": true, "哈": true, "呀": true, "嘛": true,
|
||||
"然后": true, "因为": true, "所以": true, "如果": true, "但是": true,
|
||||
"可能": true, "还是": true, "已经": true,
|
||||
"就是": true, "不是": true, "是的": true,
|
||||
"非常": true, "比较": true, "应该": true, "需要": true,
|
||||
"能够": true, "目前": true, "现在": true, "今天": true, "昨天": true,
|
||||
"明天": true, "知道": true, "觉得": true, "认为": true,
|
||||
"能": true, "没": true, "对": true,
|
||||
"the": true, "a": true, "an": true, "is": true, "are": true,
|
||||
"was": true, "were": true, "be": true, "been": true, "being": true,
|
||||
"have": true, "has": true, "had": true, "do": true, "does": true,
|
||||
"did": true, "will": true, "would": true, "could": true, "should": true,
|
||||
"may": true, "might": true, "can": true, "shall": true, "this": true,
|
||||
"that": true, "these": true, "those": true, "it": true, "its": true,
|
||||
"and": true, "or": true, "but": true, "in": true, "on": true,
|
||||
"at": true, "to": true, "for": true, "of": true, "with": true,
|
||||
"what": true, "how": true, "why": true, "which": true, "where": true,
|
||||
"when": true, "who": true, "whom": true,
|
||||
}
|
||||
|
||||
func ExtractKeywords(text string) []string {
|
||||
x := GetJieba()
|
||||
if x == nil {
|
||||
return nil
|
||||
}
|
||||
words := x.Cut(text, true)
|
||||
var keywords []string
|
||||
seen := make(map[string]bool)
|
||||
for _, w := range words {
|
||||
if stopWords[w] || seen[w] {
|
||||
continue
|
||||
}
|
||||
r := []rune(w)
|
||||
if len(r) < 2 {
|
||||
continue
|
||||
}
|
||||
seen[w] = true
|
||||
keywords = append(keywords, w)
|
||||
}
|
||||
if len(keywords) > 5 {
|
||||
keywords = keywords[:5]
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
@ -388,7 +389,7 @@ func summarizeEntries(entries []ContextEntry) string {
|
||||
var topics []string
|
||||
for _, e := range entries {
|
||||
sources[e.Source]++
|
||||
words := extractKeywords(e.Content)
|
||||
words := memory.ExtractKeywords(e.Content)
|
||||
topics = append(topics, words...)
|
||||
}
|
||||
|
||||
@ -420,7 +421,7 @@ func summarizeEntries(entries []ContextEntry) string {
|
||||
func extractTags(entries []ContextEntry) []string {
|
||||
tagSet := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
for _, kw := range extractKeywords(e.Content) {
|
||||
for _, kw := range memory.ExtractKeywords(e.Content) {
|
||||
tagSet[kw] = true
|
||||
}
|
||||
}
|
||||
@ -439,7 +440,7 @@ func extractEntities(entries []ContextEntry) []string {
|
||||
var entities []string
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
for _, kw := range extractKeywords(e.Content) {
|
||||
for _, kw := range memory.ExtractKeywords(e.Content) {
|
||||
if len(kw) >= 2 && !seen[kw] {
|
||||
seen[kw] = true
|
||||
entities = append(entities, kw)
|
||||
@ -452,32 +453,6 @@ func extractEntities(entries []ContextEntry) []string {
|
||||
return entities
|
||||
}
|
||||
|
||||
func extractKeywords(text string) []string {
|
||||
stopWords := map[string]bool{
|
||||
"的": true, "了": true, "是": true, "在": true, "有": true,
|
||||
"和": true, "就": true, "不": true, "人": true, "都": true,
|
||||
"一": true, "一个": true, "上": true, "也": true, "很": true,
|
||||
"到": true, "说": true, "要": true, "去": true, "你": true,
|
||||
"会": true, "着": true, "没有": true, "看": true, "好": true,
|
||||
"自己": true, "这": true, "他": true, "她": true, "它": true,
|
||||
"什么": true, "怎么": true, "为什么": true, "如何": true,
|
||||
"我": true, "我们": true, "你们": true, "他们": true, "这个": true,
|
||||
"那个": true, "可以": true, "吗": true, "吧": true, "啊": true,
|
||||
}
|
||||
|
||||
var keywords []string
|
||||
runes := []rune(text)
|
||||
|
||||
// bi-gram
|
||||
for i := 0; i < len(runes)-1; i++ {
|
||||
word := string(runes[i : i+2])
|
||||
if !stopWords[word] && len(strings.TrimSpace(word)) == len(word) {
|
||||
keywords = append(keywords, word)
|
||||
}
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > max {
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
func TestInsertAndQuery(t *testing.T) {
|
||||
@ -186,7 +188,7 @@ func TestSummarizeEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractKeywords(t *testing.T) {
|
||||
kws := extractKeywords("今天天气很好")
|
||||
kws := memory.ExtractKeywords("今天天气很好")
|
||||
if len(kws) == 0 {
|
||||
t.Error("should extract keywords from Chinese text")
|
||||
}
|
||||
|
||||
221
internal/memory/embedder.go
Normal file
221
internal/memory/embedder.go
Normal file
@ -0,0 +1,221 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/yanyiwu/gojieba"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
type LocalWordEmbedder struct {
|
||||
mu sync.RWMutex
|
||||
jieba *gojieba.Jieba
|
||||
stopWords map[string]bool
|
||||
|
||||
docFreq map[string]float64
|
||||
totalDocs int
|
||||
|
||||
coOccur map[string]map[string]float64
|
||||
|
||||
vocab map[string]bool
|
||||
trained bool
|
||||
}
|
||||
|
||||
func NewLocalWordEmbedder() *LocalWordEmbedder {
|
||||
sw := make(map[string]bool)
|
||||
for k, v := range stopWords {
|
||||
sw[k] = v
|
||||
}
|
||||
return &LocalWordEmbedder{
|
||||
jieba: GetJieba(),
|
||||
stopWords: sw,
|
||||
docFreq: make(map[string]float64),
|
||||
coOccur: make(map[string]map[string]float64),
|
||||
vocab: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *LocalWordEmbedder) tokenize(text string) []string {
|
||||
if e.jieba == nil {
|
||||
return nil
|
||||
}
|
||||
words := e.jieba.Cut(text, true)
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
for _, w := range words {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" || e.stopWords[w] || seen[w] {
|
||||
continue
|
||||
}
|
||||
runes := []rune(w)
|
||||
if len(runes) < 2 {
|
||||
continue
|
||||
}
|
||||
seen[w] = true
|
||||
result = append(result, w)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *LocalWordEmbedder) Train(docs []string) {
|
||||
if e.jieba == nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
e.docFreq = make(map[string]float64)
|
||||
e.coOccur = make(map[string]map[string]float64)
|
||||
e.vocab = make(map[string]bool)
|
||||
|
||||
tokenized := make([][]string, len(docs))
|
||||
|
||||
for i, doc := range docs {
|
||||
tokens := e.tokenize(doc)
|
||||
tokenized[i] = tokens
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range tokens {
|
||||
e.vocab[t] = true
|
||||
if !seen[t] {
|
||||
e.docFreq[t]++
|
||||
seen[t] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
e.totalDocs = len(docs)
|
||||
|
||||
windowSize := 5
|
||||
for _, tokens := range tokenized {
|
||||
for i, word := range tokens {
|
||||
start := i - windowSize
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := i + windowSize + 1
|
||||
if end > len(tokens) {
|
||||
end = len(tokens)
|
||||
}
|
||||
for j := start; j < end; j++ {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
ctx := tokens[j]
|
||||
if e.coOccur[word] == nil {
|
||||
e.coOccur[word] = make(map[string]float64)
|
||||
}
|
||||
e.coOccur[word][ctx]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for word, ctxs := range e.coOccur {
|
||||
totalPairs := 0.0
|
||||
for _, count := range ctxs {
|
||||
totalPairs += count
|
||||
}
|
||||
pWord := e.docFreq[word] / float64(e.totalDocs)
|
||||
for ctx, count := range ctxs {
|
||||
pCtx := e.docFreq[ctx] / float64(e.totalDocs)
|
||||
pJoint := count / totalPairs
|
||||
pmi := math.Log2(pJoint / (pWord * pCtx))
|
||||
if pmi <= 0 {
|
||||
delete(ctxs, ctx)
|
||||
} else {
|
||||
ctxs[ctx] = pmi
|
||||
}
|
||||
}
|
||||
e.coOccur[word] = pruneTopK(ctxs, 50)
|
||||
}
|
||||
|
||||
e.trained = true
|
||||
}
|
||||
|
||||
func pruneTopK(m map[string]float64, k int) map[string]float64 {
|
||||
if len(m) <= k {
|
||||
return m
|
||||
}
|
||||
type kv struct {
|
||||
k string
|
||||
v float64
|
||||
}
|
||||
var sorted []kv
|
||||
for key, val := range m {
|
||||
sorted = append(sorted, kv{key, val})
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].v > sorted[j].v
|
||||
})
|
||||
result := make(map[string]float64, k)
|
||||
for i := 0; i < k; i++ {
|
||||
result[sorted[i].k] = sorted[i].v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *LocalWordEmbedder) Vectorize(text string) vector.Vector {
|
||||
e.mu.RLock()
|
||||
useEmbedding := e.trained
|
||||
e.mu.RUnlock()
|
||||
|
||||
tokens := e.tokenize(text)
|
||||
if len(tokens) == 0 {
|
||||
return vector.Vector{}
|
||||
}
|
||||
|
||||
tf := make(map[string]float64)
|
||||
for _, t := range tokens {
|
||||
tf[t]++
|
||||
}
|
||||
maxTF := 0.0
|
||||
for _, count := range tf {
|
||||
if count > maxTF {
|
||||
maxTF = count
|
||||
}
|
||||
}
|
||||
|
||||
vec := make(vector.Vector)
|
||||
|
||||
if useEmbedding {
|
||||
e.mu.RLock()
|
||||
for word, count := range tf {
|
||||
tfidf := (count / maxTF) * idf(e.docFreq[word], e.totalDocs)
|
||||
|
||||
if ctxs, ok := e.coOccur[word]; ok {
|
||||
for ctx, pmi := range ctxs {
|
||||
vec[ctx] += tfidf * pmi
|
||||
}
|
||||
}
|
||||
|
||||
vec["__w__"+word] += tfidf
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
} else {
|
||||
for word, count := range tf {
|
||||
tfNorm := count / maxTF
|
||||
var df float64
|
||||
e.mu.RLock()
|
||||
df = e.docFreq[word]
|
||||
e.mu.RUnlock()
|
||||
vec[word] = tfNorm * idf(df, e.totalDocs)
|
||||
}
|
||||
}
|
||||
|
||||
return vec
|
||||
}
|
||||
|
||||
func idf(df float64, total int) float64 {
|
||||
if df <= 0 || total <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
return math.Log(float64(total+1)/(df+1)+1) + 1
|
||||
}
|
||||
|
||||
func (e *LocalWordEmbedder) Trained() bool {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.trained
|
||||
}
|
||||
@ -132,6 +132,9 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
if t.Subject == "" || t.Relation == "" || t.Object == "" {
|
||||
continue
|
||||
}
|
||||
if !validEntityName(t.Subject) || !validEntityName(t.Object) {
|
||||
continue
|
||||
}
|
||||
|
||||
subjType := t.SubjectType
|
||||
if subjType == "" {
|
||||
@ -161,11 +164,11 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
var sourceID, targetID int64
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err)
|
||||
}
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return 0, 0, fmt.Errorf("object %q: %w", t.Object, err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
@ -186,7 +189,27 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i
|
||||
return entitiesCreated, relationsCreated, nil
|
||||
}
|
||||
|
||||
func validEntityName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
r := []rune(name)
|
||||
if len(r) < 2 || len(r) > 50 {
|
||||
return false
|
||||
}
|
||||
hasLetter := false
|
||||
for _, ch := range r {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '\u4e00' && ch <= '\u9fff') || ch == '-' || ch == '_' {
|
||||
hasLetter = true
|
||||
}
|
||||
}
|
||||
return hasLetter
|
||||
}
|
||||
|
||||
func (g *GraphDB) upsertEntity(tx *sql.Tx, name string, entityType string) (int, error) {
|
||||
if !validEntityName(name) {
|
||||
return 0, nil
|
||||
}
|
||||
result, err := tx.Exec(
|
||||
`INSERT INTO entities (name, type) VALUES (?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
|
||||
@ -94,7 +94,7 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
|
||||
vectorEntities := idx.vectorSearchEntities(userInput)
|
||||
|
||||
// 2. 关键词搜索:已有逻辑
|
||||
keywords := extractKeywords(userInput)
|
||||
keywords := ExtractKeywords(userInput)
|
||||
if len(keywords) == 0 && len(vectorEntities) == 0 {
|
||||
keywords = []string{userInput}
|
||||
}
|
||||
@ -282,46 +282,6 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func extractKeywords(input string) []string {
|
||||
stopWords := map[string]bool{
|
||||
"的": true, "了": true, "是": true, "在": true, "有": true,
|
||||
"和": true, "就": true, "不": true, "人": true, "都": true,
|
||||
"一": true, "一个": true, "上": true, "也": true, "很": true,
|
||||
"到": true, "说": true, "要": true, "去": true, "你": true,
|
||||
"会": true, "着": true, "没有": true, "看": true, "好": true,
|
||||
"自己": true, "这": true, "他": true, "她": true, "它": true,
|
||||
"什么": true, "怎么": true, "为什么": true, "如何": true,
|
||||
}
|
||||
|
||||
var keywords []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
runes := []rune(input)
|
||||
|
||||
bigram := []rune{}
|
||||
for _, r := range runes {
|
||||
bigram = append(bigram, r)
|
||||
if len(bigram) >= 2 {
|
||||
word := string(bigram)
|
||||
if !stopWords[word] && !seen[word] {
|
||||
seen[word] = true
|
||||
keywords = append(keywords, word)
|
||||
}
|
||||
bigram = bigram[1:]
|
||||
}
|
||||
}
|
||||
|
||||
if len(keywords) == 0 && len(runes) > 0 {
|
||||
keywords = []string{string(runes)}
|
||||
}
|
||||
|
||||
if len(keywords) > 5 {
|
||||
keywords = keywords[:5]
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
|
||||
func buildIndexSummary(entities []Entity) string {
|
||||
if len(entities) == 0 {
|
||||
return ""
|
||||
|
||||
@ -186,9 +186,9 @@ func TestExtractKeywords(t *testing.T) {
|
||||
{"的了的", 0}, // all stop words
|
||||
}
|
||||
for _, tt := range tests {
|
||||
kw := extractKeywords(tt.input)
|
||||
kw := ExtractKeywords(tt.input)
|
||||
if len(kw) < tt.min {
|
||||
t.Errorf("extractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min)
|
||||
t.Errorf("ExtractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user