Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

336 lines
9.5 KiB
Go

package mcp
import (
"sync"
"time"
"github.com/zzet/gortex/internal/persistence"
)
// feedbackMaxEntries bounds the in-memory feedback log. Aggregation
// scans every entry and the store is reloaded in full on startup, so an
// unbounded log grows the daemon's heap and slows every AggregatedStats
// call for the life of the repo. The disk store is separately capped in
// persistence.SaveFeedback, but that trim only runs when a cache dir is
// configured; this cap also holds for the dir-less (embedded) manager
// and defends against an oversized store loaded from disk. Aggregation
// degrades gracefully — recent feedback dominates the signal, so the
// oldest entries age out first.
const feedbackMaxEntries = 4096
// trimFeedbackEntries keeps only the newest feedbackMaxEntries entries,
// copying into a fresh slice so the trimmed history is released rather
// than pinned by a shared backing array.
func trimFeedbackEntries(entries []persistence.FeedbackEntry) []persistence.FeedbackEntry {
if len(entries) <= feedbackMaxEntries {
return entries
}
trimmed := make([]persistence.FeedbackEntry, feedbackMaxEntries)
copy(trimmed, entries[len(entries)-feedbackMaxEntries:])
return trimmed
}
// feedbackManager provides thread-safe access to agent feedback data
// and handles persistence across server restarts.
type feedbackManager struct {
mu sync.Mutex
store persistence.FeedbackStore
dir string // cache directory for feedback file
}
// newFeedbackManager creates a feedback manager, loading any existing
// feedback from disk. Returns a no-op manager if dir is empty.
func newFeedbackManager(cacheDir, repoPath string) *feedbackManager {
if cacheDir == "" || repoPath == "" {
return &feedbackManager{}
}
dir := persistence.FeedbackDir(cacheDir, repoPath)
fm := &feedbackManager{dir: dir}
loaded, err := persistence.LoadFeedback(dir)
if err == nil && loaded != nil {
fm.store = *loaded
fm.store.Entries = trimFeedbackEntries(fm.store.Entries)
}
return fm
}
// Record appends a feedback entry and flushes to disk.
func (fm *feedbackManager) Record(entry persistence.FeedbackEntry) error {
fm.mu.Lock()
defer fm.mu.Unlock()
entry.Timestamp = time.Now()
// Stamp the task's keyword cluster so feedback can be scoped to the
// querying task and never contaminate an unrelated one.
if len(entry.Keywords) == 0 && entry.Task != "" {
entry.Keywords = keywordTokens(entry.Task)
}
fm.store.Entries = append(fm.store.Entries, entry)
fm.store.Entries = trimFeedbackEntries(fm.store.Entries)
if fm.dir == "" {
return nil
}
return persistence.SaveFeedback(fm.dir, &fm.store)
}
// symbolStats holds aggregated feedback counts for a single symbol.
type symbolStats struct {
UsefulCount int
NotNeededCount int
MissingCount int
}
// Score returns a value in [-1, 1] representing how useful this symbol has been.
// Positive = consistently useful, negative = consistently not needed.
func (ss symbolStats) Score() float64 {
total := ss.UsefulCount + ss.NotNeededCount
if total == 0 {
return 0
}
return float64(ss.UsefulCount-ss.NotNeededCount) / float64(total)
}
// GetSymbolScore returns the GLOBAL feedback score for a symbol (across
// every task). Prefer GetSymbolScoreForQuery, which scopes the score to
// the querying task's keyword cluster to avoid cross-task contamination.
func (fm *feedbackManager) GetSymbolScore(symbolID string) float64 {
return fm.GetSymbolScoreForQuery(symbolID, "")
}
// GetSymbolScoreForQuery returns the feedback score for a symbol scoped
// to the task cluster of query: only entries whose keyword set overlaps
// the query's keywords (plus legacy entries with no keywords) are
// counted. An empty query falls back to the global score. This is the
// fix for the contamination where a symbol marked useful for task A
// boosted it for unrelated task B.
func (fm *feedbackManager) GetSymbolScoreForQuery(symbolID, query string) float64 {
fm.mu.Lock()
defer fm.mu.Unlock()
return fm.aggregateSymbolScoped(symbolID, queryKeywordSet(query)).Score()
}
// aggregateSymbolScoped computes stats for one symbol across the entries
// whose keyword cluster matches qset. Caller must hold fm.mu.
func (fm *feedbackManager) aggregateSymbolScoped(symbolID string, qset map[string]struct{}) symbolStats {
var ss symbolStats
for _, e := range fm.store.Entries {
if !entryMatchesKeywords(e, qset) {
continue
}
for _, id := range e.Useful {
if id == symbolID {
ss.UsefulCount++
}
}
for _, id := range e.NotNeeded {
if id == symbolID {
ss.NotNeededCount++
}
}
for _, id := range e.Missing {
if id == symbolID {
ss.MissingCount++
}
}
}
return ss
}
// queryKeywordSet returns the set of keyword-cluster tokens for a query.
// Empty when the query has no usable keywords (the global scope).
func queryKeywordSet(query string) map[string]struct{} {
kws := keywordTokens(query)
if len(kws) == 0 {
return nil
}
set := make(map[string]struct{}, len(kws))
for _, k := range kws {
set[k] = struct{}{}
}
return set
}
// entryMatchesKeywords reports whether a feedback entry belongs to the
// task cluster identified by qset. An empty qset (global query) matches
// every entry; an entry with no keywords (legacy / keyword-less task)
// matches any query for backward compatibility; otherwise the two
// clusters must share at least one keyword.
func entryMatchesKeywords(e persistence.FeedbackEntry, qset map[string]struct{}) bool {
if len(qset) == 0 || len(e.Keywords) == 0 {
return true
}
for _, k := range e.Keywords {
if _, ok := qset[k]; ok {
return true
}
}
return false
}
// AggregatedStats returns summary statistics across all feedback entries.
func (fm *feedbackManager) AggregatedStats(toolSource string, topN int) map[string]any {
fm.mu.Lock()
defer fm.mu.Unlock()
if topN <= 0 {
topN = 10
}
// Collect all symbol IDs and their stats.
allStats := make(map[string]*symbolStats)
totalUseful := 0
totalNotNeeded := 0
matchingEntries := 0
for _, e := range fm.store.Entries {
if toolSource != "" && toolSource != "all" && e.Source != toolSource {
continue
}
matchingEntries++
for _, id := range e.Useful {
totalUseful++
if _, ok := allStats[id]; !ok {
allStats[id] = &symbolStats{}
}
allStats[id].UsefulCount++
}
for _, id := range e.NotNeeded {
totalNotNeeded++
if _, ok := allStats[id]; !ok {
allStats[id] = &symbolStats{}
}
allStats[id].NotNeededCount++
}
for _, id := range e.Missing {
if _, ok := allStats[id]; !ok {
allStats[id] = &symbolStats{}
}
allStats[id].MissingCount++
}
}
// Build ranked lists.
type ranked struct {
ID string `json:"id"`
Score float64 `json:"score"`
Count int `json:"count"`
}
var mostUseful, mostMissed, mostDemoted []ranked
for id, ss := range allStats {
if ss.UsefulCount > 0 {
mostUseful = append(mostUseful, ranked{ID: id, Score: ss.Score(), Count: ss.UsefulCount})
}
if ss.MissingCount > 0 {
mostMissed = append(mostMissed, ranked{ID: id, Score: ss.Score(), Count: ss.MissingCount})
}
if ss.NotNeededCount > 0 {
mostDemoted = append(mostDemoted, ranked{ID: id, Score: ss.Score(), Count: ss.NotNeededCount})
}
}
// Sort and trim.
sortDesc := func(s []ranked, byCount bool) []ranked {
for i := range s {
for j := i + 1; j < len(s); j++ {
swap := false
if byCount {
swap = s[j].Count > s[i].Count
} else {
swap = s[j].Score > s[i].Score
}
if swap {
s[i], s[j] = s[j], s[i]
}
}
}
if len(s) > topN {
s = s[:topN]
}
return s
}
mostUseful = sortDesc(mostUseful, false)
mostMissed = sortDesc(mostMissed, true)
mostDemoted = sortDesc(mostDemoted, true)
accuracy := 0.0
if totalUseful+totalNotNeeded > 0 {
accuracy = float64(totalUseful) / float64(totalUseful+totalNotNeeded)
}
return map[string]any{
"total_entries": matchingEntries,
"accuracy": accuracy,
"most_useful": mostUseful,
"most_missed": mostMissed,
"most_demoted": mostDemoted,
}
}
// HasData returns true if there is any feedback recorded.
func (fm *feedbackManager) HasData() bool {
fm.mu.Lock()
defer fm.mu.Unlock()
return len(fm.store.Entries) > 0
}
// MissedSymbols returns symbol IDs reported missing at least minCount
// times across ALL tasks, sorted by miss frequency descending.
func (fm *feedbackManager) MissedSymbols(minCount int) []string {
return fm.missedSymbols(minCount, nil)
}
// MissedSymbolsForQuery is MissedSymbols scoped to the querying task's
// keyword cluster — only "missing" reports from overlapping tasks count,
// so a force-inject driven by this list surfaces symbols relevant to the
// current task, not whatever was ever reported missing anywhere.
func (fm *feedbackManager) MissedSymbolsForQuery(query string, minCount int) []string {
return fm.missedSymbols(minCount, queryKeywordSet(query))
}
func (fm *feedbackManager) missedSymbols(minCount int, qset map[string]struct{}) []string {
fm.mu.Lock()
defer fm.mu.Unlock()
counts := make(map[string]int)
for _, e := range fm.store.Entries {
if !entryMatchesKeywords(e, qset) {
continue
}
for _, id := range e.Missing {
counts[id]++
}
}
type mc struct {
id string
count int
}
var result []mc
for id, c := range counts {
if c >= minCount {
result = append(result, mc{id, c})
}
}
// Sort by count descending.
for i := range result {
for j := i + 1; j < len(result); j++ {
if result[j].count > result[i].count {
result[i], result[j] = result[j], result[i]
}
}
}
ids := make([]string, len(result))
for i, r := range result {
ids[i] = r.id
}
return ids
}