From ec9d74d12846fa190e181e84c5a4e4cd351c4577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 05:51:32 -0400 Subject: [PATCH] Q27: knowledge corrections keep a revision history Co-Authored-By: Claude Opus 5.5 --- .../migrations/064-knowledge-revisions.ts | 24 +++++++ src/main/database/schema.ts | 2 + src/main/services/agent-knowledge-tools.ts | 55 +++++++++++++++- src/main/services/agent-tools.ts | 4 +- src/main/services/knowledge-service.ts | 65 ++++++++++++++++++- src/shared/commands/collaboration.ts | 13 ++++ tests/unit/domain-services.test.ts | 2 +- tests/unit/knowledge-revisions.test.ts | 61 +++++++++++++++++ 8 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 src/main/database/migrations/064-knowledge-revisions.ts create mode 100644 tests/unit/knowledge-revisions.test.ts diff --git a/src/main/database/migrations/064-knowledge-revisions.ts b/src/main/database/migrations/064-knowledge-revisions.ts new file mode 100644 index 00000000..3e9150eb --- /dev/null +++ b/src/main/database/migrations/064-knowledge-revisions.ts @@ -0,0 +1,24 @@ +export const knowledgeRevisionsSchema = ` + create table if not exists knowledge_revisions ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + entry_id text not null, + revision integer not null, + topic text not null, + category text not null, + content text not null, + importance text not null, + changed_by_type text not null, + changed_by_id text, + reason text not null, + created_at text not null, + unique (entry_id, revision) + ); + + insert into knowledge_revisions(id, org_id, entry_id, revision, topic, category, content, importance, changed_by_type, changed_by_id, reason, created_at) + select 'knr_' || lower(hex(randomblob(16))), org_id, id, 1, topic, category, content, importance, source_type, source_id, 'created', created_at + from knowledge_entries; + + insert into schema_migrations(version, name, applied_at) + values (64, 'knowledge_revisions', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 0b4211ef..e5f1d5b8 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -62,6 +62,7 @@ import { chatWakeTextPurgeSchema } from "./migrations/060-chat-wake-text-purge"; import { taskRunContextSchema } from "./migrations/061-task-run-context"; import { providerContextWindowSchema } from "./migrations/062-provider-context-window"; import { knowledgeScopeSchema } from "./migrations/063-knowledge-scope"; +import { knowledgeRevisionsSchema } from "./migrations/064-knowledge-revisions"; interface Migration { version: number; @@ -133,6 +134,7 @@ const MIGRATIONS: Migration[] = [ { version: 61, name: "task_run_context", sql: taskRunContextSchema }, { version: 62, name: "provider_context_window", sql: providerContextWindowSchema }, { version: 63, name: "knowledge_scope", sql: knowledgeScopeSchema }, + { version: 64, name: "knowledge_revisions", sql: knowledgeRevisionsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/agent-knowledge-tools.ts b/src/main/services/agent-knowledge-tools.ts index 16bb6486..dade3bf0 100644 --- a/src/main/services/agent-knowledge-tools.ts +++ b/src/main/services/agent-knowledge-tools.ts @@ -25,15 +25,16 @@ interface KnowledgeRow { export const KNOWLEDGE_TOOLS: ToolDefinition[] = [ { name: "search_knowledge", - description: "Search the company knowledge base — decisions, preferences, verified facts, and lessons recorded by the team.", + description: "Search the company knowledge base — decisions, preferences, verified facts, and lessons recorded by the team. Pass id instead of query to read one entry with its full revision history.", parameters: { type: "object", additionalProperties: false, properties: { query: { type: "string", description: "Words to look for." }, + id: { type: "string", description: "One entry's id, to read it and every earlier version." }, limit: { type: "number", description: "Most entries to return (default 10, max 30)." }, }, - required: ["query"], + required: [], }, }, { @@ -54,9 +55,28 @@ export const KNOWLEDGE_TOOLS: ToolDefinition[] = [ required: ["topic", "content", "category"], }, }, + { + name: "revise_knowledge", + description: "Correct an existing knowledge entry in place. Fields you omit keep their value; the earlier version stays in its history with your reason.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + id: { type: "string" }, + topic: { type: "string" }, + content: { type: "string" }, + category: { type: "string" }, + importance: { type: "string", enum: ["low", "normal", "high"] }, + reason: { type: "string", description: "What was wrong or what changed." }, + }, + required: ["id", "reason"], + }, + }, ]; export function searchKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { + const id = optionalText(args.id, "id"); + if (id) return entryHistory(deps.db, ctx.orgId, id); const query = requireString(args.query, "query"); const limit = optionalLimit(args.limit, "limit", 10, 30); const found = relevantItems(knowledgeRows(deps.db, readerOf(ctx)), describeEntry, query, limit); @@ -83,6 +103,37 @@ export function recordKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, return `Recorded knowledge entry ${id}.`; } +export function reviseKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { + const id = requireString(args.id, "id"); + const current = deps.db.prepare("select topic, category, content, importance from knowledge_entries where id = ? and org_id = ?") + .get(id, ctx.orgId) as { topic: string; category: string; content: string; importance: string } | undefined; + if (!current) throw new Error(`No knowledge entry with id "${id}".`); + const revision = new KnowledgeService(deps.db).update({ + orgId: ctx.orgId, + entryId: id, + topic: optionalText(args.topic, "topic") ?? current.topic, + category: optionalText(args.category, "category") ?? current.category, + content: optionalText(args.content, "content") ?? current.content, + importance: optionalEnum(args.importance, "importance", IMPORTANCE, current.importance), + reason: requireString(args.reason, "reason"), + changedByType: "agent", + changedById: ctx.agent.id, + }); + return `Knowledge entry ${id} is now at revision ${revision}.`; +} + +function entryHistory(db: DatabaseSync, orgId: string, id: string): string { + const revisions = new KnowledgeService(db).revisions(orgId, id); + if (revisions.length === 0) return `No knowledge entry with id "${id}".`; + const entry = db.prepare("select superseded_by from knowledge_entries where id = ? and org_id = ?") + .get(id, orgId) as { superseded_by: string | null } | undefined; + const state = !entry ? "deleted" : entry.superseded_by ? `superseded by ${entry.superseded_by}` : "current"; + return [ + `Knowledge entry ${id} (${state}), ${revisions.length} revision(s):`, + ...revisions.map((rev) => `r${rev.revision} ${rev.created_at} by ${rev.changed_by_type}${rev.changed_by_id ? ` ${rev.changed_by_id}` : ""} — ${rev.reason}\n ${rev.topic} [${rev.category}, ${rev.importance}]: ${rev.content}`), + ].join("\n"); +} + /** Prompt lines for the entries most relevant to a task. */ export function relevantKnowledgeLines(db: DatabaseSync, reader: KnowledgeReader, taskText: string): string[] { const found = relevantItems(knowledgeRows(db, reader), describeEntry, taskText, PROMPT_ENTRY_LIMIT); diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 128bfffb..0c4f3b79 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -11,7 +11,7 @@ import { bindSkillSlugs, grantSkillTool, listSkillsTool } from "./agent-skill-to import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools"; import { companyStatusTool, GOAL_TOOLS, updateGoalTool } from "./agent-goal-tools"; import { ATTACHMENT_TOOLS, readAttachmentTool } from "./agent-attachment-tools"; -import { KNOWLEDGE_TOOLS, recordKnowledgeTool, searchKnowledgeTool } from "./agent-knowledge-tools"; +import { KNOWLEDGE_TOOLS, recordKnowledgeTool, reviseKnowledgeTool, searchKnowledgeTool } from "./agent-knowledge-tools"; import { sendMessageTool } from "./agent-send-message"; import { withSavepoint } from "../database/connection"; import { optionalString, optionalStringArray, requireString } from "./agent-tool-args"; @@ -265,6 +265,8 @@ export function dispatchAgentTool( return searchKnowledgeTool(deps, ctx, call.arguments); case "record_knowledge": return recordKnowledgeTool(deps, ctx, call.arguments); + case "revise_knowledge": + return reviseKnowledgeTool(deps, ctx, call.arguments); case "read_attachment": return readAttachmentTool(deps, ctx, call.arguments); case "company_status": diff --git a/src/main/services/knowledge-service.ts b/src/main/services/knowledge-service.ts index 121f77c1..f1e3e11d 100644 --- a/src/main/services/knowledge-service.ts +++ b/src/main/services/knowledge-service.ts @@ -1,5 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; -import type { CreateKnowledgeEntryInput, DeleteKnowledgeEntryInput, KnowledgeScopeType } from "../../shared/commands"; +import type { CreateKnowledgeEntryInput, DeleteKnowledgeEntryInput, KnowledgeScopeType, UpdateKnowledgeEntryInput } from "../../shared/commands"; import { createId } from "../../shared/ids"; import { utcTimestamp } from "./agent-tool-args"; import { AuditService } from "./audit-service"; @@ -33,6 +33,18 @@ export function applicableKnowledgeArgs(reader: KnowledgeReader): Array { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64]); connection.close(); }); diff --git a/tests/unit/knowledge-revisions.test.ts b/tests/unit/knowledge-revisions.test.ts new file mode 100644 index 00000000..af495b41 --- /dev/null +++ b/tests/unit/knowledge-revisions.test.ts @@ -0,0 +1,61 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { KnowledgeService } from "../../src/main/services/knowledge-service"; +import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; +import { knowledgeRevisionsSchema } from "../../src/main/database/migrations/064-knowledge-revisions"; + +describe("knowledge revisions", () => { + test("a correction keeps the earlier version, the reason, and who changed it", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-revisions-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let agentId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "R", employeeName: "Y", mission: "m", goal: "g" }); + agentId = organization.createAgent({ orgId, name: "Ana", role: "Analyst", department: "Operations", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null }); + }); + const ctx: AgentToolContext = { + orgId, agent: { id: agentId, modelProviderId: null, modelId: null }, + taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null, + }; + const run = (name: string, args: Record) => + dispatchAgentTool({ db: connection.db, organization, chat: new ChatService(connection.db) }, ctx, { id: `c_${randomUUID()}`, name, arguments: args }); + + const id = /kne_\w+/.exec(run("record_knowledge", { topic: "Refund window", content: "Refunds within 14 days.", category: "policy" }))![0]; + expect(run("revise_knowledge", { id, content: "Refunds within 30 days.", reason: "Legal confirmed the 30-day rule" })).toBe(`Knowledge entry ${id} is now at revision 2.`); + expect(connection.db.prepare("select content, topic from knowledge_entries where id = ?").get(id)).toEqual({ content: "Refunds within 30 days.", topic: "Refund window" }); + + const history = run("search_knowledge", { id }); + expect(history).toContain(`Knowledge entry ${id} (current), 2 revision(s):`); + expect(history).toMatch(/r1 .* by agent .* — created\n {2}Refund window \[policy, normal\]: Refunds within 14 days\./); + expect(history).toMatch(/r2 .* by agent .* — Legal confirmed the 30-day rule\n {2}Refund window \[policy, normal\]: Refunds within 30 days\./); + + expect(run("revise_knowledge", { id, content: "x" })).toBe('ERROR: "reason" is required.'); + expect(run("revise_knowledge", { id: "kne_missing", reason: "r" })).toBe('ERROR: No knowledge entry with id "kne_missing".'); + + new KnowledgeService(connection.db).delete({ orgId, entryId: id }); + expect(run("search_knowledge", { id })).toContain(`Knowledge entry ${id} (deleted), 2 revision(s):`); + connection.close(); + }); + + test("the migration gives every existing entry its first revision", () => { + const db = new DatabaseSync(join(tmpdir(), `ac-revmig-${randomUUID()}.db`)); + db.exec(` + create table organizations(id text primary key); + create table schema_migrations(version integer primary key, name text not null, applied_at text not null); + create table knowledge_entries(id text primary key, org_id text, topic text, category text, content text, importance text, source_type text, source_id text, created_at text); + insert into organizations values ('org_1'); + insert into knowledge_entries values ('kne_1', 'org_1', 'T', 'fact', 'C', 'normal', 'employee', null, '2026-01-01T00:00:00.000Z'); + `); + db.exec(knowledgeRevisionsSchema); + expect(db.prepare("select entry_id, revision, content, reason, created_at from knowledge_revisions").all()) + .toEqual([{ entry_id: "kne_1", revision: 1, content: "C", reason: "created", created_at: "2026-01-01T00:00:00.000Z" }]); + db.close(); + }); +});