Q27: knowledge corrections keep a revision history
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
18128b5ad4
commit
ec9d74d128
8 files changed
+221
-5
No files matched your search
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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<string |
|
||||
return [reader.orgId, new Date().toISOString(), reader.projectId, reader.goalId, reader.taskId, reader.agentId];
|
||||
}
|
||||
|
||||
export interface KnowledgeRevision {
|
||||
revision: number;
|
||||
topic: string;
|
||||
category: string;
|
||||
content: string;
|
||||
importance: string;
|
||||
changed_by_type: string;
|
||||
changed_by_id: string | null;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class KnowledgeService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
@@ -54,6 +66,11 @@ export class KnowledgeService {
|
||||
if (input.supersedesId) {
|
||||
this.db.prepare("update knowledge_entries set superseded_by = ?, updated_at = ? where id = ?").run(id, now, input.supersedesId);
|
||||
}
|
||||
this.writeRevision({
|
||||
orgId: input.orgId, entryId: id, revision: 1, topic: input.topic.trim(), category: input.category, content: input.content,
|
||||
importance: input.importance, changedByType: input.sourceType, changedById: input.sourceId,
|
||||
reason: input.supersedesId ? `created, superseding ${input.supersedesId}` : "created", at: now,
|
||||
});
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "knowledge_entry",
|
||||
@@ -66,11 +83,57 @@ export class KnowledgeService {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Corrects an entry in place; the prior content stays in its revision history. */
|
||||
update(input: UpdateKnowledgeEntryInput): number {
|
||||
const reason = input.reason.trim();
|
||||
if (!reason) throw new Error("A knowledge correction needs a reason.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update knowledge_entries set topic = ?, category = ?, content = ?, importance = ?, updated_at = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(input.topic.trim(), input.category, input.content, input.importance, now, input.entryId, input.orgId);
|
||||
if (result.changes === 0) throw new Error(`No knowledge entry with id "${input.entryId}".`);
|
||||
const last = this.db.prepare("select max(revision) as revision from knowledge_revisions where entry_id = ?")
|
||||
.get(input.entryId) as { revision: number };
|
||||
const revision = last.revision + 1;
|
||||
this.writeRevision({
|
||||
orgId: input.orgId, entryId: input.entryId, revision, topic: input.topic.trim(), category: input.category, content: input.content,
|
||||
importance: input.importance, changedByType: input.changedByType, changedById: input.changedById, reason, at: now,
|
||||
});
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "knowledge_entry",
|
||||
entityId: input.entryId,
|
||||
actorType: input.changedByType === "agent" ? "agent" : "employee",
|
||||
actorId: input.changedById,
|
||||
action: "revised",
|
||||
payload: { revision, reason },
|
||||
});
|
||||
return revision;
|
||||
}
|
||||
|
||||
revisions(orgId: string, entryId: string): KnowledgeRevision[] {
|
||||
return this.db.prepare(`
|
||||
select revision, topic, category, content, importance, changed_by_type, changed_by_id, reason, created_at
|
||||
from knowledge_revisions where entry_id = ? and org_id = ? order by revision asc
|
||||
`).all(entryId, orgId) as unknown as KnowledgeRevision[];
|
||||
}
|
||||
|
||||
delete(input: DeleteKnowledgeEntryInput): void {
|
||||
const result = this.db.prepare("delete from knowledge_entries where id = ? and org_id = ?").run(input.entryId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Knowledge entry was not found.");
|
||||
}
|
||||
|
||||
private writeRevision(input: {
|
||||
orgId: string; entryId: string; revision: number; topic: string; category: string; content: string;
|
||||
importance: string; changedByType: string; changedById: string | null; reason: string; at: string;
|
||||
}): void {
|
||||
this.db.prepare(`
|
||||
insert into knowledge_revisions(id, org_id, entry_id, revision, topic, category, content, importance, changed_by_type, changed_by_id, reason, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(createId("knr"), input.orgId, input.entryId, input.revision, input.topic, input.category, input.content, input.importance, input.changedByType, input.changedById, input.reason, input.at);
|
||||
}
|
||||
|
||||
private assertScope(orgId: string, scopeType: KnowledgeScopeType, scopeId: string | null): void {
|
||||
if (scopeType === "organization") {
|
||||
if (scopeId !== null) throw new Error("Organization-wide knowledge takes no scope id.");
|
||||
|
||||
@@ -42,6 +42,19 @@ export interface CreateKnowledgeEntryInput {
|
||||
supersedesId: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateKnowledgeEntryInput {
|
||||
orgId: string;
|
||||
entryId: string;
|
||||
topic: string;
|
||||
category: string;
|
||||
content: string;
|
||||
importance: string;
|
||||
/** Why it changed; kept in the revision history. */
|
||||
reason: string;
|
||||
changedByType: string;
|
||||
changedById: string | null;
|
||||
}
|
||||
|
||||
export interface DeleteKnowledgeEntryInput {
|
||||
orgId: string;
|
||||
entryId: string;
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("domain services", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user