Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions docs/knowledge-graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,16 @@ whole MCP server.

**The loop:**

1. **Capture (optional).** With the per-workspace *intent capture* switch on,
every MCP tool gains an optional `_intent` parameter; the agent records *why*
it's calling the tool. The value is stripped before the upstream call and
stored for analysis (never sent to the API).
1. **Capture (optional).** For users who contribute to learning (see
[Who contributes](#who-contributes-to-skill-learning)), every MCP tool gains
an optional `_intent` parameter; the agent records *why* it's calling the
tool. The value is stripped before the upstream call and stored for analysis
(never sent to the API). Other users' tools do not get the parameter.
2. **Suggest.** An AI pass over the captured intents proposes skills (title,
*when to use*, *instruction*), scoped per connector or per server.
*when to use*, *instruction*), scoped per connector or per server. Generating
again replaces only the previous AI suggestions that are still pending:
skills you created by hand and skills filed by the Agent Skills Finder are
kept.
3. **Review.** On the **Skills** page each suggestion has **Apply / Edit /
Dismiss / Delete**. The list has status tabs with counts (All / Suggested /
Active / Dismissed), a search box and pagination, so it stays usable with
Expand All @@ -109,6 +113,26 @@ whole MCP server.
target selector as Generate). Pending and dismissed skills are left untouched,
and a bad/empty model reply never wipes live skills.

### Who contributes to skill learning

Whether a user's requests are captured is decided per user and per workspace:

- **Workspace default:** the *Intent capture* switch (Settings → Organization →
Features). Users set to *Default* follow it.
- **Per user (admins):** Settings → Users → *Skill learning* sets a user to
*Default*, *On* or *Off*. Select several users to change them at once; when
turning learning off you can also erase the requests already captured from
them.
- **Opt-out (each user):** Settings → Profile → *Skill learning*. A user can
always opt out and erase their own captured requests. Removing the opt-out
does not turn learning on: the admin's setting applies again.

A caller with no user identity (an instance-level static API key or bearer, a
client-credentials token) never contributes. Every change is recorded in the
security event log as `LEARNING_CHANGED`. Settings belong to the workspace
membership, so the same person can have different settings in two workspaces.
Cloud and self-hosted behave the same.

### Skills are instructions, not extra tools

> **Important:** applied skills do **not** add tool calls to your MCP surface.
Expand Down Expand Up @@ -167,7 +191,7 @@ tiny built-in client (no SDK). Two ways to run them:
|---|---|
| Knowledge Graph | Master switch — gates build, serve, cron and the MCP tool. |
| AI enrichment | Allows the LLM graph pass for this workspace. |
| Intent capture | Adds the optional `_intent` param to this workspace's tools. |
| Intent capture | Default for users set to *Default*: adds the optional `_intent` param to their tools. Override per user under Settings → Users. |
| Scheduled AI extension | Lets the cloud cron extend graph + skills for this workspace. |

**Instance env** (see `.env.example` for the full list):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Where a workspace skill came from, so skill generation only replaces its
-- own pending suggestions (it used to delete skills filed by the Agent Skills
-- Finder and manual pending ones too).
ALTER TABLE "kg_skill_suggestions" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual';

-- Backfill. Finder skills end with the "Source: <url> (licence: …)" line that
-- skills_save_to_workspace appends; manual skills are created with
-- confidence 1, generated and consolidated ones carry the model's score.
UPDATE "kg_skill_suggestions"
SET "source" = 'finder'
WHERE "instruction" ~ E'\n\nSource: https?://\\S+ \\(licence: [^)]*\\)$';

UPDATE "kg_skill_suggestions"
SET "source" = 'llm'
WHERE "source" = 'manual' AND "confidence" < 1;

-- Per-member control over skill learning (intent capture).
ALTER TABLE "organization_members" ADD COLUMN "learning_mode" TEXT NOT NULL DEFAULT 'inherit';
ALTER TABLE "organization_members" ADD COLUMN "learning_opt_out" BOOLEAN NOT NULL DEFAULT false;
11 changes: 11 additions & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ model OrganizationMember {
/// reactivation, and so the member list can say "deactivated" instead of
/// making a leaver indistinguishable from someone who was never here.
deactivatedAt DateTime? @map("deactivated_at")
/// Whether this member's requests may feed skill learning (intent capture):
/// `inherit` follows the workspace switch, `on` / `off` override it. Set by
/// an admin. Per membership, so one person can differ between workspaces.
learningMode String @default("inherit") @map("learning_mode")
/// The member's own opt-out. Wins over `learningMode`; a member can opt
/// out but never opt in against the admin.
learningOptOut Boolean @default(false) @map("learning_opt_out")

@@unique([userId, organizationId])
@@index([userId])
Expand Down Expand Up @@ -1368,6 +1375,10 @@ model KgSkillSuggestion {
confidence Float @default(0)
evidenceCount Int @default(0) @map("evidence_count")
status String @default("pending") // pending | applied | dismissed
// Where the skill came from: manual (UI/API) | llm (generated or
// consolidated) | finder (skills_save_to_workspace). Generation only
// replaces its own pending `llm` rows.
source String @default("manual")

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/audit/security-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export const SecurityEvents = {
*/
WORKSPACE_SESSIONS_REVOKED: 'WORKSPACE_SESSIONS_REVOKED',
API_KEY_DEACTIVATED: 'API_KEY_DEACTIVATED',
/**
* Whether a member's requests may feed skill learning changed: an admin set
* their mode, or the member opted out / back in. One row per member.
*/
LEARNING_CHANGED: 'LEARNING_CHANGED',

// ── Consent plane: which AI client was authorized, by whom ──────────────
CONSENT_GRANTED: 'CONSENT_GRANTED',
Expand Down
163 changes: 163 additions & 0 deletions packages/backend/src/knowledge-graph/kg-learning.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { NotFoundException } from '@nestjs/common';
import { KgLearningService, effectiveLearning, memberContributes } from './kg-learning.service';

const ORG = 'org-A';

describe('effectiveLearning', () => {
it.each([
// mode, optOut, workspaceDefault, expected
['inherit', false, true, true],
['inherit', false, false, false],
['on', false, false, true],
['off', false, true, false],
['on', true, true, false],
['inherit', true, true, false],
['garbage', false, true, true],
])('%s / optOut=%s / default=%s → %s', (mode, optOut, def, expected) => {
expect(effectiveLearning(mode as string, optOut as boolean, def as boolean)).toBe(expected);
});
});

describe('memberContributes', () => {
function deps(member: unknown, workspaceDefault = true) {
const prisma = {
organizationMember: { findUnique: jest.fn().mockResolvedValue(member) },
};
const staticSvc = { getFlag: jest.fn().mockResolvedValue(workspaceDefault) };
return { prisma, staticSvc };
}

it('is false without a user (static instance credentials)', async () => {
const { prisma, staticSvc } = deps({ learningMode: 'on', learningOptOut: false, deactivatedAt: null });
expect(await memberContributes(prisma as any, staticSvc as any, ORG, undefined)).toBe(false);
expect(prisma.organizationMember.findUnique).not.toHaveBeenCalled();
});

it('is false for a caller who is not a member of the workspace', async () => {
const { prisma, staticSvc } = deps(null);
expect(await memberContributes(prisma as any, staticSvc as any, ORG, 'client:abc')).toBe(false);
});

it('is false for a deactivated member', async () => {
const { prisma, staticSvc } = deps({
learningMode: 'on',
learningOptOut: false,
deactivatedAt: new Date(),
});
expect(await memberContributes(prisma as any, staticSvc as any, ORG, 'u1')).toBe(false);
});

it('follows the workspace switch for inherit and looks the member up in this workspace', async () => {
const { prisma, staticSvc } = deps({ learningMode: 'inherit', learningOptOut: false, deactivatedAt: null });
expect(await memberContributes(prisma as any, staticSvc as any, ORG, 'u1')).toBe(true);
expect(prisma.organizationMember.findUnique.mock.calls[0][0].where).toEqual({
userId_organizationId: { userId: 'u1', organizationId: ORG },
});
expect(staticSvc.getFlag).toHaveBeenCalledWith(ORG, 'kg_capture_intent', false);
});

it('honours an admin off even when the workspace switch is on', async () => {
const { prisma, staticSvc } = deps({ learningMode: 'off', learningOptOut: false, deactivatedAt: null });
expect(await memberContributes(prisma as any, staticSvc as any, ORG, 'u1')).toBe(false);
});
});

describe('KgLearningService', () => {
const ctx = { actorUserId: 'admin', ip: '1.2.3.4', userAgent: 'jest' };

function make(members: Array<{ userId: string; learningMode: string; learningOptOut: boolean }>, workspaceDefault = false) {
const prisma = {
organizationMember: {
findMany: jest.fn(async ({ where }: any) =>
members.filter((m) => where.userId.in.includes(m.userId)),
),
findUnique: jest.fn(async ({ where }: any) =>
members.find((m) => m.userId === where.userId_organizationId.userId) ?? null,
),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
update: jest.fn().mockResolvedValue({}),
},
toolInvocation: { updateMany: jest.fn().mockResolvedValue({ count: 3 }) },
};
const staticSvc = { getFlag: jest.fn().mockResolvedValue(workspaceDefault) };
const securityEvents = { log: jest.fn().mockResolvedValue(undefined) };
const svc = new KgLearningService(prisma as any, staticSvc as any, securityEvents as any);
return { svc, prisma, securityEvents };
}

it('sets the mode of members of this workspace only and logs one event per changed member', async () => {
const { svc, prisma, securityEvents } = make([
{ userId: 'u1', learningMode: 'inherit', learningOptOut: false },
{ userId: 'u2', learningMode: 'on', learningOptOut: false },
]);
const res = await svc.setMode(ORG, ['u1', 'u2', 'stranger'], 'on', ctx);

expect(res).toEqual({ updated: 1, notFound: ['stranger'], intentsCleared: 0 });
expect(prisma.organizationMember.findMany.mock.calls[0][0].where.organizationId).toBe(ORG);
expect(prisma.organizationMember.updateMany).toHaveBeenCalledWith({
where: { organizationId: ORG, userId: { in: ['u1'] } },
data: { learningMode: 'on' },
});
expect(securityEvents.log).toHaveBeenCalledTimes(1);
expect(securityEvents.log.mock.calls[0][0]).toEqual(
expect.objectContaining({
event: 'LEARNING_CHANGED',
organizationId: ORG,
actorUserId: 'admin',
targetUserId: 'u1',
metadata: { from: 'inherit', to: 'on', via: 'admin' },
}),
);
});

it('clears captured intents only for members whose learning ends up off, in this workspace', async () => {
const { svc, prisma } = make([
{ userId: 'u1', learningMode: 'on', learningOptOut: false },
{ userId: 'u2', learningMode: 'on', learningOptOut: false },
]);
const res = await svc.setMode(ORG, ['u1', 'u2'], 'off', ctx, { clearIntents: true });

expect(res.intentsCleared).toBe(3);
expect(prisma.toolInvocation.updateMany).toHaveBeenCalledWith({
where: { organizationId: ORG, userId: { in: ['u1', 'u2'] }, intent: { not: null } },
data: { intent: null },
});
});

it('does not clear intents when the members stay on', async () => {
const { svc, prisma } = make([{ userId: 'u1', learningMode: 'off', learningOptOut: false }]);
await svc.setMode(ORG, ['u1'], 'on', ctx, { clearIntents: true });
expect(prisma.toolInvocation.updateMany).not.toHaveBeenCalled();
});

it('lets a member opt out, but opting back in only restores the admin setting', async () => {
const members = [{ userId: 'u1', learningMode: 'off', learningOptOut: true }];
const { svc, prisma } = make(members, true);
const res = await svc.setOptOut(ORG, 'u1', false, { actorUserId: 'u1' });
expect(prisma.organizationMember.update).toHaveBeenCalledWith({
where: { userId_organizationId: { userId: 'u1', organizationId: ORG } },
data: { learningOptOut: false },
});
// The mock row is not mutated, but the admin's "off" decides either way.
expect(res.effective).toBe(false);
});

it('erases the member\'s own intents when opting out with clearIntents', async () => {
const { svc, prisma } = make([{ userId: 'u1', learningMode: 'on', learningOptOut: false }]);
const res = await svc.setOptOut(ORG, 'u1', true, { actorUserId: 'u1' }, { clearIntents: true });
expect(res.intentsCleared).toBe(3);
expect(prisma.toolInvocation.updateMany.mock.calls[0][0].where).toEqual({
organizationId: ORG,
userId: { in: ['u1'] },
intent: { not: null },
});
});

it('refuses a user who is not a member of the workspace', async () => {
const { svc } = make([]);
await expect(svc.getForMember(ORG, 'nobody')).rejects.toBeInstanceOf(NotFoundException);
await expect(svc.setOptOut(ORG, 'nobody', true, { actorUserId: 'nobody' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
Loading
Loading