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