package httpapi import ( "encoding/json" "fmt" "log/slog" "net/http" "sort" "strconv" "strings" ) // Drift tooling for the knowledge base — the maintenance half of the wiki. // // These endpoints exist because the knowledge base measurably rots on its // own. Two failure modes are already present in live data: // // - **Duplicate pileup.** upsert_knowledge keys on exact title, so a note // titled "rclone backup live inspection — 2026-07-15 10:08 UTC" and one // titled "... 11:18 UTC" are different notes. A single day of agent // activity produced eight near-identical investigations that should have // been one living page. Nothing surfaced that, so it kept happening. // - **Tag drift.** `oom` and `OOM` were separate tags; so were `422` and // `proton-422`. Each split halves the usefulness of tag navigation, and // neither is visible from any single note. // // normalizeTags (knowledge_write.go) stops new casing splits at the door; // these endpoints clean up what's already there and make the rot visible. // serveKnowledgeTags returns the tag index: every tag with its usage count, // plus the distinct casings actually stored. `variants` is the interesting // column — it's how the operator discovers that `oom` and `OOM` are the same // idea filed twice, which no individual note reveals. func (s *Server) serveKnowledgeTags(w http.ResponseWriter, req *http.Request) { ctx := req.Context() rows, err := s.pool.Query(ctx, ` SELECT lower(tag) AS norm, count(*) AS uses, array_agg(DISTINCT tag ORDER BY tag) AS variants FROM knowledge_entities ke, unnest(ke.tags) AS tag WHERE ke.deleted_at IS NULL GROUP BY lower(tag) ORDER BY uses DESC, norm`) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) return } defer rows.Close() type tagRow struct { Tag string `json:"tag"` Uses int `json:"uses"` Variants []string `json:"variants"` // True when the same tag is stored under more than one casing — // the UI badges these as needing a normalize. Split bool `json:"split"` } items := []tagRow{} for rows.Next() { var t tagRow if err := rows.Scan(&t.Tag, &t.Uses, &t.Variants); err != nil { slog.Error("httpapi: knowledge/tags row scan failed", "error", err) continue } t.Split = len(t.Variants) > 1 items = append(items, t) } writeJSON(w, map[string]any{"items": items}) } // serveRenameKnowledgeTag rewrites one or more tags to a single target across // every live note — the merge/rename/normalize action behind the tag manager. // Passing several `from` values into one `to` is the merge case // (`{"from":["422","proton-422"],"to":"proton-422"}`); passing one is a plain // rename; passing the mixed-case variants is the normalize case. func (s *Server) serveRenameKnowledgeTag(w http.ResponseWriter, req *http.Request) { ctx := req.Context() var body struct { From []string `json:"from"` To string `json:"to"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error()) return } to := strings.ToLower(strings.TrimSpace(body.To)) from := []string{} for _, f := range body.From { if f = strings.TrimSpace(f); f != "" { from = append(from, f) } } if to == "" || len(from) == 0 { writeProblem(w, req, http.StatusBadRequest, "from and to are required", "") return } // Rebuild each affected note's tag array: map every `from` member to // `to`, leave everything else alone, then de-duplicate. The dedupe // matters for the merge case — a note tagged both `422` and // `proton-422` would otherwise end up with `proton-422` twice. // // This is a plain UPDATE on knowledge_entities, so trg_knowledge_revision // fires and every affected note gets a revision. A tag merge across 17 // notes is exactly the kind of bulk edit worth being able to inspect // afterwards. tag, err := s.pool.Exec(ctx, ` UPDATE knowledge_entities ke SET tags = sub.new_tags, updated_at = now() FROM ( SELECT k.entity_id, ARRAY(SELECT DISTINCT CASE WHEN lower(t) = ANY($1) THEN $2 ELSE t END FROM unnest(k.tags) AS t) AS new_tags FROM knowledge_entities k WHERE k.deleted_at IS NULL AND EXISTS (SELECT 1 FROM unnest(k.tags) AS t WHERE lower(t) = ANY($1)) ) AS sub WHERE ke.entity_id = sub.entity_id`, lowerAll(from), to) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "rename failed", err.Error()) return } _, actorLabel := actorInfo(ctx) slog.Info("knowledge tags renamed", "from", from, "to", to, "notes", tag.RowsAffected(), "actor", actorLabel) writeJSON(w, map[string]any{"ok": true, "notes_updated": tag.RowsAffected()}) } // serveKnowledgeDuplicates clusters notes whose titles are near-identical. // // Pairwise trigram similarity is computed in SQL (indexed, and the whole // point of pulling in pg_trgm); the grouping is done here in Go. Returning // clusters rather than pairs matters for the real data: the rclone pileup // produces dozens of pairs, which is unreadable, versus one cluster, which // is the actionable unit. // // The grouping uses **complete linkage** — a note joins a cluster only if it // is similar to every member already in it. The obvious implementation // (union-find over the pairs) is single linkage, and on this data it chains // badly: "A~B, B~C" merged notes that were not remotely alike, collapsing // fifteen distinct backup events into one unusable blob. Requiring mutual // similarity keeps clusters tight enough to act on. // // Even so, these are *candidates for review*, never a verdict. The five // "Lifecycle: a node" runbooks are mutually similar by title and are // five deliberately distinct documents — no threshold distinguishes them // from a genuine duplicate, so merging stays a manual, previewed action. func (s *Server) serveKnowledgeDuplicates(w http.ResponseWriter, req *http.Request) { ctx := req.Context() // 0.6, tuned against the live data: at 0.45 the "Lifecycle: a // node" runbooks (five deliberately distinct documents that happen to // share a naming template) formed a false-positive cluster; 0.6 clears // that down to a single borderline pair while keeping every genuine // duplicate cluster (the rclone/apt-audit/uptime pileups) intact. // Tunable per request — the UI exposes this as the review net widens. threshold := 0.6 if t := req.URL.Query().Get("threshold"); t != "" { if v, err := strconv.ParseFloat(t, 64); err == nil && v > 0 && v <= 1 { threshold = v } } rows, err := s.pool.Query(ctx, ` SELECT a.slug, b.slug, similarity(ka.title, kb.title) AS sim FROM knowledge_entities ka JOIN knowledge_entities kb ON ka.entity_id < kb.entity_id JOIN entities a ON a.id = ka.entity_id JOIN entities b ON b.id = kb.entity_id WHERE ka.deleted_at IS NULL AND kb.deleted_at IS NULL AND similarity(ka.title, kb.title) > $1 ORDER BY sim DESC`, threshold) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) return } defer rows.Close() type pair struct { A, B string Sim float64 } pairs := []pair{} for rows.Next() { var p pair if err := rows.Scan(&p.A, &p.B, &p.Sim); err != nil { slog.Error("httpapi: knowledge/duplicates row scan failed", "error", err) continue } pairs = append(pairs, p) } // Complete-linkage grouping. `pairs` arrives sorted by similarity // descending, so each new cluster is seeded from the strongest remaining // pair and then only grows with notes that are similar to *everything* // already inside it. sim := make(map[string]float64, len(pairs)*2) key := func(a, b string) string { if a > b { a, b = b, a } return a + "\x00" + b } for _, p := range pairs { sim[key(p.A, p.B)] = p.Sim } linked := func(a, b string) bool { return sim[key(a, b)] > 0 } assigned := map[string]bool{} type rawCluster struct { members []string top float64 } raw := []rawCluster{} for _, p := range pairs { if assigned[p.A] || assigned[p.B] { continue } c := rawCluster{members: []string{p.A, p.B}, top: p.Sim} assigned[p.A], assigned[p.B] = true, true // Sweep the remaining pairs for candidates that connect to every // current member. Repeat until a full pass adds nothing, since // admitting one member can qualify another. for grew := true; grew; { grew = false for _, q := range pairs { for _, cand := range []string{q.A, q.B} { if assigned[cand] { continue } ok := true for _, m := range c.members { if !linked(cand, m) { ok = false break } } if ok { c.members = append(c.members, cand) assigned[cand] = true grew = true } } } } raw = append(raw, c) } groups := map[string][]string{} best := map[string]float64{} for _, c := range raw { root := c.members[0] groups[root] = c.members best[root] = c.top } // Re-fetch display detail for the clustered slugs only. type member struct { Slug string `json:"slug"` Title string `json:"title"` Kind string `json:"kind"` Size int `json:"size"` UpdatedAt string `json:"updated_at"` EditedBy string `json:"edited_by"` } detail := map[string]member{} if len(groups) > 0 { all := []string{} for _, g := range groups { all = append(all, g...) } drows, derr := s.pool.Query(ctx, ` SELECT e.slug, ke.title, e.type, length(ke.content), ke.updated_at::text, COALESCE(ke.edited_by,'') FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE e.slug = ANY($1) AND ke.deleted_at IS NULL`, all) if derr != nil { writeProblem(w, req, http.StatusInternalServerError, "detail query failed", derr.Error()) return } defer drows.Close() for drows.Next() { var m member if err := drows.Scan(&m.Slug, &m.Title, &m.Kind, &m.Size, &m.UpdatedAt, &m.EditedBy); err != nil { slog.Error("httpapi: knowledge/duplicates detail scan failed", "error", err) continue } detail[m.Slug] = m } } type cluster struct { Members []member `json:"members"` TopSim float64 `json:"top_similarity"` TotalSize int `json:"total_size"` } out := []cluster{} for root, slugs := range groups { c := cluster{TopSim: best[root]} for _, sl := range slugs { if m, ok := detail[sl]; ok { c.Members = append(c.Members, m) c.TotalSize += m.Size } } if len(c.Members) < 2 { continue } // Newest first inside a cluster — the most recent note is usually // the one worth keeping as the merge target. sort.Slice(c.Members, func(i, j int) bool { return c.Members[i].UpdatedAt > c.Members[j].UpdatedAt }) out = append(out, c) } // Biggest clusters first: an eight-note pileup deserves attention before // a two-note coincidence. sort.Slice(out, func(i, j int) bool { if len(out[i].Members) != len(out[j].Members) { return len(out[i].Members) > len(out[j].Members) } return out[i].TopSim > out[j].TopSim }) writeJSON(w, map[string]any{"clusters": out, "threshold": threshold}) } // serveKnowledgeOrphans surfaces notes that have fallen out of every // navigation path — the ones that are technically present but effectively // unreachable, and so quietly stop being maintained. // // Three independent reasons, reported per note (a note can have several): // - untagged: invisible to tag navigation // - unlinked: not `about` any entity, so it never appears on a machine's page // - stale: untouched for 90+ days func (s *Server) serveKnowledgeOrphans(w http.ResponseWriter, req *http.Request) { ctx := req.Context() staleDays := 90 if d := req.URL.Query().Get("stale_days"); d != "" { if v, err := strconv.Atoi(d); err == nil && v > 0 && v <= 3650 { staleDays = v } } rows, err := s.pool.Query(ctx, fmt.Sprintf(` SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''), ke.updated_at::text, (ke.tags IS NULL OR cardinality(ke.tags) = 0) AS untagged, NOT EXISTS ( SELECT 1 FROM relationships r WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL AND r.type IN ('documents', 'about') ) AS unlinked, (ke.updated_at < now() - interval '%d days') AS stale FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE ke.deleted_at IS NULL ORDER BY ke.updated_at ASC`, staleDays)) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error()) return } defer rows.Close() type orphan struct { Slug string `json:"slug"` Title string `json:"title"` Kind string `json:"kind"` EditedBy string `json:"edited_by"` UpdatedAt string `json:"updated_at"` Reasons []string `json:"reasons"` } items := []orphan{} counts := map[string]int{"untagged": 0, "unlinked": 0, "stale": 0} for rows.Next() { var o orphan var untagged, unlinked, stale bool if err := rows.Scan(&o.Slug, &o.Title, &o.Kind, &o.EditedBy, &o.UpdatedAt, &untagged, &unlinked, &stale); err != nil { slog.Error("httpapi: knowledge/orphans row scan failed", "error", err) continue } o.Reasons = []string{} if untagged { o.Reasons = append(o.Reasons, "untagged") counts["untagged"]++ } if unlinked { o.Reasons = append(o.Reasons, "unlinked") counts["unlinked"]++ } if stale { o.Reasons = append(o.Reasons, "stale") counts["stale"]++ } if len(o.Reasons) > 0 { items = append(items, o) } } writeJSON(w, map[string]any{ "items": items, "counts": counts, "stale_days": staleDays, }) } // serveMergeKnowledge folds several notes into one: each source's body is // appended to the target under a provenance heading, the union of all tags is // kept, and the sources are soft-deleted. // // Append rather than discard, and soft-delete rather than hard: a merge is a // judgement call made from a similarity score, and the operator needs to be // able to walk it back. The target's pre-merge state is captured by the // revision trigger, so the merge itself is undoable from the History tab. func (s *Server) serveMergeKnowledge(w http.ResponseWriter, req *http.Request) { ctx := req.Context() var body struct { Target string `json:"target"` Sources []string `json:"sources"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error()) return } if strings.TrimSpace(body.Target) == "" || len(body.Sources) == 0 { writeProblem(w, req, http.StatusBadRequest, "target and sources are required", "") return } targetID, err := s.resolveKnowledgeEntity(ctx, body.Target) if err != nil { writeProblem(w, req, http.StatusNotFound, "target note not found", body.Target) return } _, actorLabel := actorInfo(ctx) tx, err := s.pool.Begin(ctx) if err != nil { writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error()) return } defer tx.Rollback(ctx) var merged []string var appended strings.Builder tagSet := map[string]bool{} for _, srcSlug := range body.Sources { if srcSlug == body.Target { continue // merging a note into itself would duplicate its body } var srcTitle, srcContent, srcUpdated string var srcTags []string err := tx.QueryRow(ctx, ` SELECT ke.title, ke.content, COALESCE(ke.tags,'{}'), ke.updated_at::text FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`, srcSlug).Scan(&srcTitle, &srcContent, &srcTags, &srcUpdated) if err != nil { slog.Warn("knowledge merge: source not found, skipping", "slug", srcSlug) continue } appended.WriteString("\n\n---\n\n## Merged: ") appended.WriteString(srcTitle) appended.WriteString("\n\n*Originally ") appended.WriteString(srcSlug) appended.WriteString(", last updated ") appended.WriteString(srcUpdated) appended.WriteString("*\n\n") appended.WriteString(srcContent) for _, t := range srcTags { tagSet[strings.ToLower(strings.TrimSpace(t))] = true } merged = append(merged, srcSlug) } if len(merged) == 0 { writeProblem(w, req, http.StatusBadRequest, "no valid source notes to merge", "") return } extraTags := make([]string, 0, len(tagSet)) for t := range tagSet { if t != "" { extraTags = append(extraTags, t) } } sort.Strings(extraTags) // The array concat + DISTINCT keeps the target's own tags first and adds // only what the sources contribute. if _, err := tx.Exec(ctx, ` UPDATE knowledge_entities SET content = content || $2, tags = ARRAY(SELECT DISTINCT unnest(COALESCE(tags,'{}') || $3::text[])), edited_by = $4, updated_at = now() WHERE entity_id = $1`, targetID, appended.String(), extraTags, actorLabel); err != nil { writeProblem(w, req, http.StatusInternalServerError, "merge write failed", err.Error()) return } for _, srcSlug := range merged { if _, err := tx.Exec(ctx, ` UPDATE knowledge_entities ke SET deleted_at = now(), edited_by = $2 FROM entities e WHERE e.id = ke.entity_id AND (e.slug = $1 OR e.id::text = $1)`, srcSlug, actorLabel); err != nil { writeProblem(w, req, http.StatusInternalServerError, "source delete failed", err.Error()) return } } if err := tx.Commit(ctx); err != nil { writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error()) return } slog.Info("knowledge merged", "target", body.Target, "sources", merged, "actor", actorLabel) writeJSON(w, map[string]any{"ok": true, "merged": merged, "tags_added": extraTags}) } // lowerAll is the case-folding helper the tag queries compare against. func lowerAll(in []string) []string { out := make([]string, len(in)) for i, s := range in { out[i] = strings.ToLower(strings.TrimSpace(s)) } return out }