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)
|
||||
|
||||
Reference in New Issue
Block a user