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() }