The Knowledge page was read-only from the HTTP API — the only writer was the agent's MCP upsert_knowledge tool. Adds create/update/soft-delete/ restore/trash endpoints, a DB-trigger-backed revision history (catches both the web UI and the MCP tool), and maintenance endpoints: duplicate detection (pg_trgm + complete-linkage clustering), tag rename/normalize, orphan detection, and merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
120 lines
5.8 KiB
PL/PgSQL
120 lines
5.8 KiB
PL/PgSQL
-- 022_knowledge_revisions.up.sql
|
|
-- Version history for knowledge_entities, so an edit can never be silently lost.
|
|
--
|
|
-- The concrete hazard this closes: the MCP tool `upsert_knowledge`
|
|
-- (internal/mcp/server.go) keys on title and does
|
|
-- `ON CONFLICT (entity_id) DO UPDATE SET content = EXCLUDED.content` —
|
|
-- unconditionally. Before this migration, an operator hand-editing a note in
|
|
-- the web UI would have that edit overwritten with no trace the next time
|
|
-- Nomos re-upserted a note with the same title. There was no history table
|
|
-- and no way to recover the prior body.
|
|
--
|
|
-- The snapshot is a BEFORE UPDATE **trigger** rather than application-level
|
|
-- code in the HTTP handler, specifically because there are two independent
|
|
-- writers: the web API (new in this change) and the MCP tool the agent uses.
|
|
-- App-level snapshotting would only cover whichever path remembered to call
|
|
-- it. A trigger covers both, plus any future writer and any manual psql fix.
|
|
--
|
|
-- Each row in knowledge_revisions is a *superseded* version: the state of the
|
|
-- note before the update that displaced it. The current version always lives
|
|
-- in knowledge_entities, never here, so "history" is
|
|
-- knowledge_entities + knowledge_revisions ordered by version_at DESC.
|
|
|
|
-- Who authored the version currently in knowledge_entities. Distinct from
|
|
-- `source`, which is overloaded: it holds either 'nomos-agent' (written via
|
|
-- MCP) or a seed file path ('containers/101-jellyfin') and is NOT updated on
|
|
-- conflict, so a seeded doc later rewritten by the agent still reports its
|
|
-- original file path. edited_by answers the question the UI actually asks —
|
|
-- "did a human or the agent last touch this?" — without disturbing source,
|
|
-- which the seeding logic still relies on.
|
|
ALTER TABLE knowledge_entities
|
|
ADD COLUMN IF NOT EXISTS edited_by TEXT NOT NULL DEFAULT '';
|
|
|
|
-- Backfill: every existing row's last writer is whatever source says. For
|
|
-- agent-written notes that's exactly right; for seeded notes it records the
|
|
-- seed path, which is the honest answer (no human has edited them yet).
|
|
UPDATE knowledge_entities
|
|
SET edited_by = COALESCE(source, '')
|
|
WHERE edited_by = '';
|
|
|
|
-- Soft delete. A hard DELETE would cascade knowledge_revisions away with the
|
|
-- entity, which contradicts the point of this migration — removing a note is
|
|
-- exactly the moment its history matters most. Deleting sets deleted_at; all
|
|
-- read paths filter it out, the revision trail survives, and an accidental
|
|
-- delete is recoverable by clearing the column.
|
|
ALTER TABLE knowledge_entities
|
|
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
|
|
|
-- Partial index: every list/search/read query carries `deleted_at IS NULL`,
|
|
-- and deleted notes are expected to stay a small minority.
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_live
|
|
ON knowledge_entities (updated_at DESC)
|
|
WHERE deleted_at IS NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS knowledge_revisions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
source TEXT,
|
|
tags TEXT[],
|
|
edited_by TEXT NOT NULL DEFAULT '',
|
|
-- When this version was written (the superseded row's updated_at).
|
|
version_at TIMESTAMPTZ NOT NULL,
|
|
-- When it was replaced. version_at of revision N and revised_at of
|
|
-- revision N-1 bracket how long that version was the live one.
|
|
revised_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- The only access pattern: "show me the history of this note, newest first."
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_revisions_entity
|
|
ON knowledge_revisions (entity_id, version_at DESC);
|
|
|
|
-- Snapshot the outgoing row whenever the substance changes. Deliberately
|
|
-- ignores updated_at-only touches: upsert_knowledge sets `updated_at = now()`
|
|
-- on every call even when re-writing byte-identical content (it has no
|
|
-- change detection), and without this guard a re-run of the same agent task
|
|
-- would pile up identical revisions and bury the real edits.
|
|
--
|
|
-- `search` is a GENERATED column and is intentionally not carried into
|
|
-- revisions — it is derived from title+content and would be dead weight.
|
|
CREATE OR REPLACE FUNCTION snapshot_knowledge_revision() RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF OLD.title IS DISTINCT FROM NEW.title
|
|
OR OLD.content IS DISTINCT FROM NEW.content
|
|
OR OLD.tags IS DISTINCT FROM NEW.tags THEN
|
|
INSERT INTO knowledge_revisions
|
|
(entity_id, title, content, source, tags, edited_by, version_at)
|
|
VALUES
|
|
(OLD.entity_id, OLD.title, OLD.content, OLD.source, OLD.tags,
|
|
OLD.edited_by, OLD.updated_at);
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- DROP + CREATE rather than CREATE OR REPLACE: Postgres 16 has no
|
|
-- CREATE OR REPLACE TRIGGER for this form, and the migration must stay
|
|
-- re-runnable.
|
|
DROP TRIGGER IF EXISTS trg_knowledge_revision ON knowledge_entities;
|
|
|
|
CREATE TRIGGER trg_knowledge_revision
|
|
BEFORE UPDATE ON knowledge_entities
|
|
FOR EACH ROW EXECUTE FUNCTION snapshot_knowledge_revision();
|
|
|
|
-- Trigram similarity, for the duplicate-detection view. The knowledge base
|
|
-- has already accumulated near-duplicates that exact matching cannot catch —
|
|
-- four separate "rclone backup live inspection — <date>" investigations, each
|
|
-- a fresh note where an update to the existing one was meant. upsert_knowledge
|
|
-- keys on exact title, so a date suffix is enough to fork a new note.
|
|
--
|
|
-- similarity() over titles is what lets the UI cluster those and offer a
|
|
-- merge. fuzzystrmatch (levenshtein) was the alternative; trigram wins here
|
|
-- because these titles differ by whole appended words rather than typos, and
|
|
-- because it comes with a GIN index while levenshtein cannot be indexed.
|
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_knowledge_title_trgm
|
|
ON knowledge_entities USING gin (title gin_trgm_ops)
|
|
WHERE deleted_at IS NULL;
|