140 lines
4.9 KiB
Go
140 lines
4.9 KiB
Go
package store
|
||
|
||
// Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden
|
||
// (migrations/006_suchprotokoll.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.schreiben.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, aelterAls string) (int64, error) {
|
||
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, aelterAls)
|
||
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 prozent(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 prozent(s.WithSynonyms, s.Total)
|
||
}
|
||
|
||
// SearchStatsFor zählt die Suchen eines Projekts im Zeitraum UsageWindow.
|
||
func (s *Store) SearchStatsFor(ctx context.Context, projectID string, jetzt time.Time) (SearchStats, error) {
|
||
seit := jetzt.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.lesen.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, seit,
|
||
).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, jetzt time.Time, limit int) ([]EmptySearch, error) {
|
||
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
|
||
|
||
// MAX(ts) ist der jüngste Zeitpunkt. Die Synonyme der jüngsten Zeile holt
|
||
// eine Eigenheit von SQLite: Steht MAX() in der Abfrage, kommen die übrigen
|
||
// Spalten ohne Aggregat aus genau der Zeile mit dem Höchstwert.
|
||
rows, err := s.lesen.QueryContext(ctx,
|
||
`SELECT query, synonyms, COUNT(*) AS anzahl, MAX(ts)
|
||
FROM search_log
|
||
WHERE project_id = ? AND ts >= ? AND results = 0
|
||
GROUP BY lower(trim(query))
|
||
ORDER BY anzahl DESC, MAX(ts) DESC
|
||
LIMIT ?`,
|
||
projectID, seit, limit,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Suchen ohne Treffer konnten nicht gelesen werden: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var liste []EmptySearch
|
||
for rows.Next() {
|
||
var e EmptySearch
|
||
var zuletzt string
|
||
if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &zuletzt); err != nil {
|
||
return nil, err
|
||
}
|
||
e.Last = parseTime(zuletzt)
|
||
liste = append(liste, e)
|
||
}
|
||
return liste, rows.Err()
|
||
}
|