munin/internal/store/searchlog.go
2026-10-19 10:13:56 +02:00

149 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
// Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden
// (migrations/006_search_log.sql).
import (
"context"
"fmt"
"log"
"strings"
"time"
)
// SearchLogRetention ist die Aufbewahrungsfrist des Suchprotokolls. Gleich
// dem Zeitraum der Nutzungsübersicht: Länger braucht die Auswertung die
// Zeilen nicht, und Anfragen enthalten Inhalt — der soll nicht länger liegen
// als nötig.
const SearchLogRetention = UsageWindow
// SearchLogEntry ist eine Suche, wie sie ins Protokoll geht.
type SearchLogEntry struct {
ProjectID string
APIKeyID string
Query string
Synonyms []string
Results int // Zahl der ausgegebenen Treffer, nach dem Schlagwortfilter
}
// LogSearch schreibt eine Suche ins Protokoll.
//
// Wie LogAudit: Ein Fehler lässt die Suche nicht scheitern, er landet nur im
// Serverlog. Der Agent soll seine Treffer bekommen, auch wenn die
// Protokollzeile nicht geschrieben werden konnte.
func (s *Store) LogSearch(ctx context.Context, e SearchLogEntry) {
_, err := s.write.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, api_key_id, query, synonyms, results)
VALUES (?, ?, ?, ?, ?, ?)`,
NowString(), e.ProjectID, e.APIKeyID, e.Query, strings.Join(e.Synonyms, ", "), e.Results,
)
if err != nil {
log.Printf("Suchprotokoll: Suche konnte nicht vermerkt werden: %v", err)
}
}
// PruneSearchLog löscht Suchen, die älter als die angegebene Grenze sind.
// Die Grenze ist ein Zeitstempel im Datenbankformat, wie bei PruneAudit.
func (s *Store) PruneSearchLog(ctx context.Context, olderThan string) (int64, error) {
res, err := s.write.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, olderThan)
if err != nil {
return 0, fmt.Errorf("Suchprotokoll konnte nicht aufgeräumt werden: %w", err)
}
return res.RowsAffected()
}
// SearchStats sind die Kennzahlen des Suchprotokolls eines Projekts.
type SearchStats struct {
Total int // alle Suchen im Zeitraum
Empty int // davon ohne Treffer
WithSynonyms int // davon mit Synonymen
}
// EmptyPercent ist der Anteil der Suchen ohne Treffer in Prozent (0–100).
func (s SearchStats) EmptyPercent() int {
return percent(s.Empty, s.Total)
}
// SynonymPercent ist der Anteil der Suchen mit Synonymen in Prozent (0–100).
// Zeigt, ob die Agenten die Bitte aus den instructions befolgen.
func (s SearchStats) SynonymPercent() int {
return percent(s.WithSynonyms, s.Total)
}
// SearchStatsFor zählt die Suchen eines Projekts im Zeitraum UsageWindow.
func (s *Store) SearchStatsFor(ctx context.Context, projectID string, now time.Time) (SearchStats, error) {
since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// Wie in UsageSummaries: CASE … THEN 1 ELSE 0 macht aus jeder Zeile eine
// 1 oder 0, SUM zählt. COALESCE, weil SUM über null Zeilen NULL liefert.
var st SearchStats
err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*),
COALESCE(SUM(CASE WHEN results = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN synonyms <> '' THEN 1 ELSE 0 END), 0)
FROM search_log
WHERE project_id = ? AND ts >= ?`,
projectID, since,
).Scan(&st.Total, &st.Empty, &st.WithSynonyms)
if err != nil {
return SearchStats{}, fmt.Errorf("Suchprotokoll konnte nicht gelesen werden: %w", err)
}
return st, nil
}
// EmptySearch ist eine Anfrage, die nichts gefunden hat — zusammengefasst
// über alle Male, die sie gestellt wurde.
type EmptySearch struct {
Query string
Synonyms string // die zuletzt mitgegebenen Synonyme
Count int
Last time.Time
}
// EmptySearches gibt die Anfragen ohne Treffer zurück, häufigste zuerst,
// höchstens limit Stück.
//
// Gleiche Anfragen werden ohne Rücksicht auf Groß- und Kleinschreibung
// zusammengefasst. lower() kennt in SQLite nur ASCII — „Über" und „über"
// bleiben also zwei Zeilen. Für eine Übersicht reicht das.
func (s *Store) EmptySearches(ctx context.Context, projectID string, now time.Time, limit int) ([]EmptySearch, error) {
since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// Je Anfrage (ohne Rücksicht auf Groß-/Kleinschreibung und Leerzeichen am
// Rand) eine Zeile: die jüngste, mit ihren Synonymen. COUNT(*) OVER zählt
// alle Male, die die Anfrage gestellt wurde; ROW_NUMBER() nummeriert sie,
// jüngste zuerst. id entscheidet bei gleichem Zeitstempel.
//
// Früher stand hier MAX(ts) mit Spalten ohne Aggregat — eine Eigenheit von
// SQLite, die PostgreSQL ablehnt. Fensterfunktionen können beide (siehe
// CuratedOverrides und AUSBAUSTUFEN.md 4.10).
rows, err := s.read.QueryContext(ctx,
`SELECT query, synonyms, anzahl, ts FROM (
SELECT query, synonyms, ts,
COUNT(*) OVER (PARTITION BY lower(trim(query))) AS anzahl,
ROW_NUMBER() OVER (PARTITION BY lower(trim(query)) ORDER BY ts DESC, id DESC) AS nr
FROM search_log
WHERE project_id = ? AND ts >= ? AND results = 0
) AS je_anfrage
WHERE nr = 1
ORDER BY anzahl DESC, ts DESC
LIMIT ?`,
projectID, since, limit,
)
if err != nil {
return nil, fmt.Errorf("Suchen ohne Treffer konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var list []EmptySearch
for rows.Next() {
var e EmptySearch
var latest string
if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &latest); err != nil {
return nil, err
}
e.Last = parseTime(latest)
list = append(list, e)
}
return list, rows.Err()
}