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
275 lines
7.3 KiB
Go
275 lines
7.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
// handleAnalyzeRetrievalLog mines the append-only retrieval query log
|
|
// (see query_log.go) for offline recall tuning. It surfaces the
|
|
// zero-result queries — the ones BM25/the graph could not answer, the
|
|
// highest-signal candidates for synonym expansion, missing index
|
|
// coverage, or a feedback force-inject — plus per-tool latency and
|
|
// result-size rollups.
|
|
//
|
|
// Params: limit (records scanned, newest first; default 5000),
|
|
// tool (filter), zero_only (bool), since (RFC3339 prefix match),
|
|
// top (distinct zero-result questions to surface; default 20),
|
|
// include_recent (bool — attach the newest rows).
|
|
func (s *Server) handleAnalyzeRetrievalLog(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
limit := intArgOrDefault(args, "limit", 5000)
|
|
if limit <= 0 {
|
|
limit = 5000
|
|
}
|
|
if limit > 200000 {
|
|
limit = 200000
|
|
}
|
|
toolFilter := strings.TrimSpace(stringArg(args, "tool"))
|
|
zeroOnly, _ := args["zero_only"].(bool)
|
|
since := strings.TrimSpace(stringArg(args, "since"))
|
|
top := intArgOrDefault(args, "top", 20)
|
|
if top <= 0 {
|
|
top = 20
|
|
}
|
|
includeRecent, _ := args["include_recent"].(bool)
|
|
|
|
path := s.queryLog.Path()
|
|
if path == "" {
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"enabled": false,
|
|
"message": "retrieval query log is disabled (GORTEX_QUERY_LOG_DISABLE set, or no writable cache dir)",
|
|
})
|
|
}
|
|
|
|
records, scanned, readErr := readQueryLogTail(path, limit)
|
|
if readErr != nil && len(records) == 0 {
|
|
// No log yet is the common cold-start case, not an error.
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"enabled": true,
|
|
"log_path": path,
|
|
"total": 0,
|
|
"message": "no query log records yet",
|
|
})
|
|
}
|
|
|
|
// Per-tool aggregation.
|
|
type toolAgg struct {
|
|
count int
|
|
zero int
|
|
nodesSum int
|
|
durations []float64
|
|
}
|
|
byTool := map[string]*toolAgg{}
|
|
zeroCounter := map[string]*zeroQ{}
|
|
var filtered []queryLogRecord
|
|
var firstTS, lastTS string
|
|
|
|
for _, r := range records {
|
|
if toolFilter != "" && r.Tool != toolFilter {
|
|
continue
|
|
}
|
|
if since != "" && r.TS < since {
|
|
continue
|
|
}
|
|
if zeroOnly && !r.ZeroResult {
|
|
continue
|
|
}
|
|
filtered = append(filtered, r)
|
|
if firstTS == "" || r.TS < firstTS {
|
|
firstTS = r.TS
|
|
}
|
|
if r.TS > lastTS {
|
|
lastTS = r.TS
|
|
}
|
|
a := byTool[r.Tool]
|
|
if a == nil {
|
|
a = &toolAgg{}
|
|
byTool[r.Tool] = a
|
|
}
|
|
a.count++
|
|
a.nodesSum += r.NodesReturned
|
|
a.durations = append(a.durations, r.DurationMS)
|
|
if r.ZeroResult {
|
|
a.zero++
|
|
key := r.Tool + "\x00" + strings.ToLower(strings.TrimSpace(r.Question))
|
|
zq := zeroCounter[key]
|
|
if zq == nil {
|
|
zq = &zeroQ{Tool: r.Tool, Question: r.Question, Corpus: r.Corpus}
|
|
zeroCounter[key] = zq
|
|
}
|
|
zq.Count++
|
|
}
|
|
}
|
|
|
|
// Build per-tool rows.
|
|
type toolRow struct {
|
|
Tool string `json:"tool"`
|
|
Count int `json:"count"`
|
|
Zero int `json:"zero_result"`
|
|
ZeroRate float64 `json:"zero_rate"`
|
|
AvgNodes float64 `json:"avg_nodes"`
|
|
P50MS float64 `json:"p50_ms"`
|
|
P95MS float64 `json:"p95_ms"`
|
|
}
|
|
toolRows := make([]toolRow, 0, len(byTool))
|
|
totalQueries, totalZero := 0, 0
|
|
for name, a := range byTool {
|
|
totalQueries += a.count
|
|
totalZero += a.zero
|
|
sort.Float64s(a.durations)
|
|
row := toolRow{Tool: name, Count: a.count, Zero: a.zero}
|
|
if a.count > 0 {
|
|
row.ZeroRate = round4(float64(a.zero) / float64(a.count))
|
|
row.AvgNodes = round4(float64(a.nodesSum) / float64(a.count))
|
|
}
|
|
row.P50MS = round4(percentile(a.durations, 0.50))
|
|
row.P95MS = round4(percentile(a.durations, 0.95))
|
|
toolRows = append(toolRows, row)
|
|
}
|
|
sort.Slice(toolRows, func(i, j int) bool {
|
|
if toolRows[i].Count != toolRows[j].Count {
|
|
return toolRows[i].Count > toolRows[j].Count
|
|
}
|
|
return toolRows[i].Tool < toolRows[j].Tool
|
|
})
|
|
|
|
// Top zero-result questions.
|
|
zeroRows := make([]zeroQ, 0, len(zeroCounter))
|
|
for _, zq := range zeroCounter {
|
|
zeroRows = append(zeroRows, *zq)
|
|
}
|
|
sort.Slice(zeroRows, func(i, j int) bool {
|
|
if zeroRows[i].Count != zeroRows[j].Count {
|
|
return zeroRows[i].Count > zeroRows[j].Count
|
|
}
|
|
return zeroRows[i].Question < zeroRows[j].Question
|
|
})
|
|
if len(zeroRows) > top {
|
|
zeroRows = zeroRows[:top]
|
|
}
|
|
|
|
zeroRate := 0.0
|
|
if totalQueries > 0 {
|
|
zeroRate = round4(float64(totalZero) / float64(totalQueries))
|
|
}
|
|
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "scanned=%d queries=%d zero=%d (%.1f%%)\n", scanned, totalQueries, totalZero, zeroRate*100)
|
|
for _, t := range toolRows {
|
|
fmt.Fprintf(&b, "%-22s n=%-5d zero=%-4d (%.0f%%) p50=%.1fms p95=%.1fms avg_nodes=%.1f\n",
|
|
t.Tool, t.Count, t.Zero, t.ZeroRate*100, t.P50MS, t.P95MS, t.AvgNodes)
|
|
}
|
|
if len(zeroRows) > 0 {
|
|
b.WriteString("\nzero-result questions:\n")
|
|
for _, z := range zeroRows {
|
|
fmt.Fprintf(&b, " [%dx] %s: %s\n", z.Count, z.Tool, z.Question)
|
|
}
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
|
|
out := map[string]any{
|
|
"enabled": true,
|
|
"log_path": path,
|
|
"scanned": scanned,
|
|
"total": totalQueries,
|
|
"zero_results": totalZero,
|
|
"zero_rate": zeroRate,
|
|
"first_ts": firstTS,
|
|
"last_ts": lastTS,
|
|
"by_tool": toolRows,
|
|
"zero_result_questions": zeroRows,
|
|
}
|
|
if includeRecent {
|
|
n := len(filtered)
|
|
start := n - 50
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
out["recent"] = filtered[start:]
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, out)
|
|
}
|
|
|
|
// zeroQ tallies a distinct zero-result (tool, question) pair.
|
|
type zeroQ struct {
|
|
Tool string `json:"tool"`
|
|
Question string `json:"question"`
|
|
Corpus string `json:"corpus,omitempty"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// readQueryLogTail reads the JSONL query log and returns the newest
|
|
// `limit` parsed records (in chronological order) plus the total number
|
|
// of lines scanned. Malformed lines are skipped.
|
|
func readQueryLogTail(path string, limit int) ([]queryLogRecord, int, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer f.Close()
|
|
|
|
// Ring buffer of the last `limit` raw lines, then parse — bounds
|
|
// memory regardless of log size.
|
|
ring := make([]string, 0, limit)
|
|
scanned := 0
|
|
sc := bufio.NewScanner(f)
|
|
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) // tolerate large response lines
|
|
for sc.Scan() {
|
|
line := sc.Text()
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
scanned++
|
|
if len(ring) < limit {
|
|
ring = append(ring, line)
|
|
} else {
|
|
copy(ring, ring[1:])
|
|
ring[len(ring)-1] = line
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, scanned, err
|
|
}
|
|
records := make([]queryLogRecord, 0, len(ring))
|
|
for _, line := range ring {
|
|
var r queryLogRecord
|
|
if json.Unmarshal([]byte(line), &r) == nil {
|
|
records = append(records, r)
|
|
}
|
|
}
|
|
return records, scanned, nil
|
|
}
|
|
|
|
// percentile returns the nearest-rank percentile of a sorted slice.
|
|
func percentile(sorted []float64, q float64) float64 {
|
|
if len(sorted) == 0 {
|
|
return 0
|
|
}
|
|
if len(sorted) == 1 {
|
|
return sorted[0]
|
|
}
|
|
idx := int(q * float64(len(sorted)-1))
|
|
if idx < 0 {
|
|
idx = 0
|
|
}
|
|
if idx >= len(sorted) {
|
|
idx = len(sorted) - 1
|
|
}
|
|
return sorted[idx]
|
|
}
|
|
|
|
// round4 rounds to 4 decimal places for stable wire output.
|
|
func round4(f float64) float64 {
|
|
return float64(int64(f*10000+0.5)) / 10000
|
|
}
|