feat: add workspace-isolated toolhost runtime and capability-gap skill loop
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"laodingbot/internal/logger"
|
||||
@@ -110,6 +112,154 @@ func (s *SQLiteStore) LoadRecent(chatID string, limit int) ([]Message, error) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SaveCapabilityGap(chatID, userID, intent, reason string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO capability_gaps(chat_id, user_id, intent, reason, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, chatID, userID, intent, reason, time.Now().UTC())
|
||||
if err != nil && s.log != nil {
|
||||
s.log.Errorf("save capability gap failed chat_id=%s user_id=%s err=%v", chatID, userID, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) TopCapabilityGaps(limit int) ([]CapabilityGap, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, chat_id, user_id, intent, reason, created_at
|
||||
FROM capability_gaps
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Errorf("top capability gaps query failed err=%v", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]CapabilityGap, 0, limit)
|
||||
for rows.Next() {
|
||||
var item CapabilityGap
|
||||
if err := rows.Scan(&item.ID, &item.ChatID, &item.UserID, &item.Intent, &item.Reason, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) TopCapabilityGapClusters(limit int, since time.Time) ([]CapabilityGapCluster, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if since.IsZero() {
|
||||
since = time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT intent, reason, created_at
|
||||
FROM capability_gaps
|
||||
WHERE created_at >= ?
|
||||
`, since)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Errorf("top capability gap clusters query failed err=%v", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type groupKey struct {
|
||||
IntentKey string
|
||||
Reason string
|
||||
}
|
||||
type agg struct {
|
||||
cluster CapabilityGapCluster
|
||||
}
|
||||
|
||||
groups := map[groupKey]agg{}
|
||||
for rows.Next() {
|
||||
var intent string
|
||||
var reason string
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&intent, &reason, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
intentKey := normalizeIntentKey(intent)
|
||||
reason = strings.TrimSpace(reason)
|
||||
k := groupKey{IntentKey: intentKey, Reason: reason}
|
||||
current, ok := groups[k]
|
||||
if !ok {
|
||||
current = agg{cluster: CapabilityGapCluster{
|
||||
IntentKey: intentKey,
|
||||
SampleIntent: strings.TrimSpace(intent),
|
||||
Reason: reason,
|
||||
Count: 0,
|
||||
LastSeenAt: createdAt,
|
||||
}}
|
||||
}
|
||||
current.cluster.Count++
|
||||
if createdAt.After(current.cluster.LastSeenAt) {
|
||||
current.cluster.LastSeenAt = createdAt
|
||||
}
|
||||
if current.cluster.SampleIntent == "" {
|
||||
current.cluster.SampleIntent = strings.TrimSpace(intent)
|
||||
}
|
||||
groups[k] = current
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]CapabilityGapCluster, 0, len(groups))
|
||||
for _, v := range groups {
|
||||
out = append(out, v.cluster)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Count == out[j].Count {
|
||||
return out[i].LastSeenAt.After(out[j].LastSeenAt)
|
||||
}
|
||||
return out[i].Count > out[j].Count
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeIntentKey(intent string) string {
|
||||
intent = strings.ToLower(strings.TrimSpace(intent))
|
||||
if intent == "" {
|
||||
return "empty"
|
||||
}
|
||||
intent = strings.ReplaceAll(intent, " ", "")
|
||||
intent = strings.ReplaceAll(intent, "\t", "")
|
||||
intent = strings.ReplaceAll(intent, "\n", "")
|
||||
intent = strings.ReplaceAll(intent, "\r", "")
|
||||
b := strings.Builder{}
|
||||
for _, r := range intent {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || (r >= 0x4e00 && r <= 0x9fff) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
normalized := b.String()
|
||||
if normalized == "" {
|
||||
return "empty"
|
||||
}
|
||||
runes := []rune(normalized)
|
||||
if len(runes) > 80 {
|
||||
normalized = string(runes[:80])
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) migrate() error {
|
||||
stmt := `
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
@@ -121,6 +271,15 @@ func (s *SQLiteStore) migrate() error {
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_id ON messages(chat_id, id);
|
||||
CREATE TABLE IF NOT EXISTS capability_gaps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
intent TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_capability_gaps_created_at ON capability_gaps(created_at);
|
||||
`
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("migrate schema: %w", err)
|
||||
|
||||
64
internal/memory/store_sqlite_test.go
Normal file
64
internal/memory/store_sqlite_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCapabilityGapStoreAndLoad(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := NewSQLiteStore(dbPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.SaveCapabilityGap("c1", "u1", "intent-a", "reason-a"); err != nil {
|
||||
t.Fatalf("SaveCapabilityGap error: %v", err)
|
||||
}
|
||||
if err := store.SaveCapabilityGap("c1", "u1", "intent-b", "reason-b"); err != nil {
|
||||
t.Fatalf("SaveCapabilityGap error: %v", err)
|
||||
}
|
||||
|
||||
items, err := store.TopCapabilityGaps(10)
|
||||
if err != nil {
|
||||
t.Fatalf("TopCapabilityGaps error: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
if items[0].Intent != "intent-b" {
|
||||
t.Fatalf("expected newest first, got first intent=%s", items[0].Intent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopCapabilityGapClusters(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "cluster.db")
|
||||
store, err := NewSQLiteStore(dbPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.SaveCapabilityGap("c1", "u1", "帮我查询 data 目录", "no_skill_matched"); err != nil {
|
||||
t.Fatalf("SaveCapabilityGap error: %v", err)
|
||||
}
|
||||
if err := store.SaveCapabilityGap("c1", "u2", "帮我 查询 data 目录", "no_skill_matched"); err != nil {
|
||||
t.Fatalf("SaveCapabilityGap error: %v", err)
|
||||
}
|
||||
if err := store.SaveCapabilityGap("c2", "u3", "读取配置文件内容", "tool_call_failed:file"); err != nil {
|
||||
t.Fatalf("SaveCapabilityGap error: %v", err)
|
||||
}
|
||||
|
||||
clusters, err := store.TopCapabilityGapClusters(10, time.Now().UTC().Add(-1*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("TopCapabilityGapClusters error: %v", err)
|
||||
}
|
||||
if len(clusters) == 0 {
|
||||
t.Fatalf("expected non-empty clusters")
|
||||
}
|
||||
if clusters[0].Count < 2 {
|
||||
t.Fatalf("expected first cluster count >= 2, got %d", clusters[0].Count)
|
||||
}
|
||||
}
|
||||
20
internal/memory/types.go
Normal file
20
internal/memory/types.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package memory
|
||||
|
||||
import "time"
|
||||
|
||||
type CapabilityGap struct {
|
||||
ID int64
|
||||
ChatID string
|
||||
UserID string
|
||||
Intent string
|
||||
Reason string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CapabilityGapCluster struct {
|
||||
IntentKey string
|
||||
SampleIntent string
|
||||
Reason string
|
||||
Count int
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
Reference in New Issue
Block a user