- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
recall/get/list/related "stale": true; get und recall geben einen
Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
(Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
Prüfvermerk und Zeitstempel aus dem Export.
Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
270 lines
8.7 KiB
Go
270 lines
8.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Richtung eines Verweises aus Sicht des Eintrags, über den ein verwandter
|
|
// Eintrag gefunden wurde (siehe RelatedEntry.Relation).
|
|
const (
|
|
RelationLinksTo = "links_to" // der Vorgänger verweist auf diesen Eintrag
|
|
RelationLinkedBy = "linked_by" // dieser Eintrag verweist auf den Vorgänger
|
|
)
|
|
|
|
// RelatedEntry ist ein Nachbar eines Eintrags im Verweisgraphen.
|
|
type RelatedEntry struct {
|
|
Slug string
|
|
Title string
|
|
UpdatedAt time.Time
|
|
|
|
// Distance zählt die Verweise bis zum Ausgangseintrag: 1 = direkt
|
|
// verknüpft, 2 = über einen Zwischeneintrag.
|
|
Distance int
|
|
|
|
// Via ist der Eintrag, über den dieser gefunden wurde. Bei Distance 1 ist
|
|
// das der Ausgangseintrag selbst, bei Distance 2 der Zwischeneintrag.
|
|
Via string
|
|
|
|
// Relation sagt, in welche Richtung der Verweis zwischen Via und diesem
|
|
// Eintrag zeigt: RelationLinksTo oder RelationLinkedBy.
|
|
Relation string
|
|
}
|
|
|
|
// RelatedEntries liefert die Nachbarn eines Eintrags im Verweisgraphen, bis zu
|
|
// maxDistance Schritte weit und höchstens limit Stück (siehe AUSBAUSTUFEN.md
|
|
// 3.7).
|
|
//
|
|
// # Wozu, wenn "get" schon links_to und linked_by liefert?
|
|
//
|
|
// "get" kennt nur die direkten Nachbarn, und das nur als nackte Bezeichner.
|
|
// Hier kommen Titel und Änderungsdatum dazu, und auf Wunsch auch die zweite
|
|
// Reihe: Was hängt an den Einträgen, die mit meinem Thema verknüpft sind? Für
|
|
// einen Agenten ist das ein zweiter Weg zum Kontext, neben der Suche. Er
|
|
// findet Einträge, die kein Suchwort gemeinsam haben, aber von einem Menschen
|
|
// bewusst verknüpft wurden.
|
|
//
|
|
// # Vorgehen
|
|
//
|
|
// Breitensuche, eine Ebene nach der anderen: erst alle direkten Nachbarn, dann
|
|
// deren Nachbarn. Die Richtung der Verweise spielt für die Suche keine Rolle.
|
|
// Wer auf mich verweist, ist genauso verwandt wie das, worauf ich verweise.
|
|
// Näheres steht vor Fernerem, innerhalb einer Ebene nach Titel sortiert.
|
|
//
|
|
// Verweise auf Bezeichner ohne Eintrag und Einträge im Papierkorb tauchen nicht
|
|
// auf. Für einen Agenten, der Kontext sucht, gibt es dort nichts zu lesen.
|
|
//
|
|
// Gibt es den Ausgangseintrag nicht, ist das ErrNotFound.
|
|
func (s *Store) RelatedEntries(ctx context.Context, projectID, slug string, maxDistance, limit int) ([]RelatedEntry, error) {
|
|
// Den Ausgangseintrag selbst prüfen. Sonst bekäme ein Tippfehler im
|
|
// Bezeichner eine leere Liste zurück, und der Agent hielte das Thema für
|
|
// unverknüpft statt für nicht vorhanden.
|
|
var vorhanden int
|
|
if err := s.lesen.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
|
|
projectID, slug,
|
|
).Scan(&vorhanden); err != nil {
|
|
return nil, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
|
|
}
|
|
if vorhanden == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
// besucht verhindert, dass ein Eintrag zweimal auftaucht, etwa wenn A auf
|
|
// B verweist und B zurück auf A. Der Ausgangseintrag zählt von Anfang an
|
|
// als besucht, er soll nicht als sein eigener Nachbar erscheinen.
|
|
besucht := map[string]bool{slug: true}
|
|
ebene := []string{slug}
|
|
|
|
var ergebnis []RelatedEntry
|
|
|
|
for abstand := 1; abstand <= maxDistance && len(ebene) > 0 && len(ergebnis) < limit; abstand++ {
|
|
kanten, err := s.nachbarn(ctx, projectID, ebene)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Je neu entdecktem Bezeichner nur die erste Kante merken. Welcher
|
|
// Vorgänger „gewinnt“, ist egal, aber es muss derselbe sein, egal in
|
|
// welcher Reihenfolge die Datenbank die Zeilen liefert. Deshalb sind
|
|
// die Kanten vorher sortiert (siehe nachbarn).
|
|
neu := make(map[string]RelatedEntry)
|
|
var neueSlugs []string
|
|
for _, k := range kanten {
|
|
if besucht[k.nach] {
|
|
continue
|
|
}
|
|
if _, schonDa := neu[k.nach]; schonDa {
|
|
continue
|
|
}
|
|
neu[k.nach] = RelatedEntry{Slug: k.nach, Distance: abstand, Via: k.von, Relation: k.relation}
|
|
neueSlugs = append(neueSlugs, k.nach)
|
|
}
|
|
|
|
// Titel und Änderungsdatum nachladen. Dabei fallen Verweisziele ohne
|
|
// Eintrag und Einträge im Papierkorb heraus: Sie stehen nicht in
|
|
// der Antwort.
|
|
details, err := s.eintragsKoepfe(ctx, projectID, neueSlugs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var dieseEbene []RelatedEntry
|
|
ebene = ebene[:0]
|
|
for _, nachbar := range neueSlugs {
|
|
besucht[nachbar] = true
|
|
kopf, ok := details[nachbar]
|
|
if !ok {
|
|
continue
|
|
}
|
|
r := neu[nachbar]
|
|
r.Title = kopf.Title
|
|
r.UpdatedAt = kopf.UpdatedAt
|
|
dieseEbene = append(dieseEbene, r)
|
|
ebene = append(ebene, nachbar)
|
|
}
|
|
|
|
sort.Slice(dieseEbene, func(i, j int) bool { return dieseEbene[i].Title < dieseEbene[j].Title })
|
|
ergebnis = append(ergebnis, dieseEbene...)
|
|
}
|
|
|
|
if len(ergebnis) > limit {
|
|
ergebnis = ergebnis[:limit]
|
|
}
|
|
return ergebnis, nil
|
|
}
|
|
|
|
// kante ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus.
|
|
type kante struct {
|
|
von, nach string
|
|
relation string
|
|
}
|
|
|
|
// nachbarn liefert alle Verweise, die einen der Bezeichner in slugs berühren,
|
|
// in beide Richtungen.
|
|
//
|
|
// Zwei Abfragen statt einer mit OR: So nutzt jede ihren eigenen Index. Die
|
|
// ausgehenden laufen über den Primärschlüssel (from_entry_id, to_slug), die
|
|
// eingehenden über idx_entry_links_projekt (project_id, to_slug).
|
|
func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) ([]kante, error) {
|
|
platz, args := inListe(projectID, slugs)
|
|
|
|
var kanten []kante
|
|
|
|
// Ausgehend: Einträge der Ebene verweisen auf etwas.
|
|
rows, err := s.lesen.QueryContext(ctx,
|
|
`SELECT e.slug, l.to_slug
|
|
FROM entries e
|
|
JOIN entry_links l ON l.from_entry_id = e.id
|
|
WHERE e.project_id = ? AND e.slug IN (`+platz+`) AND e.deleted_at IS NULL`,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var k kante
|
|
if err := rows.Scan(&k.von, &k.nach); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
k.relation = RelationLinksTo
|
|
kanten = append(kanten, k)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
rows.Close()
|
|
|
|
// Eingehend: Etwas verweist auf Einträge der Ebene. Der verweisende
|
|
// Eintrag ist hier der Nachbar, deshalb stehen die Spalten andersherum.
|
|
rows, err = s.lesen.QueryContext(ctx,
|
|
`SELECT l.to_slug, e.slug
|
|
FROM entry_links l
|
|
JOIN entries e ON e.id = l.from_entry_id
|
|
WHERE l.project_id = ? AND l.to_slug IN (`+platz+`) AND e.deleted_at IS NULL`,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Rückverweise konnten nicht gelesen werden: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var k kante
|
|
if err := rows.Scan(&k.von, &k.nach); err != nil {
|
|
return nil, err
|
|
}
|
|
k.relation = RelationLinkedBy
|
|
kanten = append(kanten, k)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Feste Reihenfolge, damit dasselbe Projekt immer dieselbe Antwort gibt
|
|
// (siehe RelatedEntries).
|
|
sort.Slice(kanten, func(i, j int) bool {
|
|
if kanten[i].nach != kanten[j].nach {
|
|
return kanten[i].nach < kanten[j].nach
|
|
}
|
|
if kanten[i].von != kanten[j].von {
|
|
return kanten[i].von < kanten[j].von
|
|
}
|
|
return kanten[i].relation < kanten[j].relation
|
|
})
|
|
return kanten, nil
|
|
}
|
|
|
|
// eintragsKopf ist das Wenige, was RelatedEntries über einen Nachbarn braucht.
|
|
type eintragsKopf struct {
|
|
Title string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// eintragsKoepfe lädt Titel und Änderungsdatum zu einer Liste von Bezeichnern.
|
|
// Was nicht (mehr) existiert oder im Papierkorb liegt, fehlt in der Antwort.
|
|
func (s *Store) eintragsKoepfe(ctx context.Context, projectID string, slugs []string) (map[string]eintragsKopf, error) {
|
|
koepfe := make(map[string]eintragsKopf, len(slugs))
|
|
if len(slugs) == 0 {
|
|
return koepfe, nil
|
|
}
|
|
|
|
platz, args := inListe(projectID, slugs)
|
|
rows, err := s.lesen.QueryContext(ctx,
|
|
`SELECT slug, title, updated_at FROM entries
|
|
WHERE project_id = ? AND slug IN (`+platz+`) AND `+nichtImPapierkorb,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var slug, title, geaendert string
|
|
if err := rows.Scan(&slug, &title, &geaendert); err != nil {
|
|
return nil, err
|
|
}
|
|
koepfe[slug] = eintragsKopf{Title: title, UpdatedAt: parseTime(geaendert)}
|
|
}
|
|
return koepfe, rows.Err()
|
|
}
|
|
|
|
// inListe baut die Platzhalter für "IN (?, ?, …)" und die passenden
|
|
// Argumente, mit der Projekt-ID vorneweg.
|
|
//
|
|
// Die Werte werden nie in den SQL-Text geschrieben, sondern immer als
|
|
// Parameter übergeben. Nur so ist ausgeschlossen, dass ein Bezeichner die
|
|
// Abfrage verändert (SQL-Injection).
|
|
func inListe(projectID string, werte []string) (string, []any) {
|
|
args := make([]any, 0, len(werte)+1)
|
|
args = append(args, projectID)
|
|
for _, w := range werte {
|
|
args = append(args, w)
|
|
}
|
|
return strings.TrimSuffix(strings.Repeat("?, ", len(werte)), ", "), args
|
|
}
|