From a27664cda76bd5f4b2aa4bd5e5c6d06d5ae59542 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:20:07 +0200 Subject: [PATCH 1/2] Add per-member skill learning control and fix skill provenance (#1145) Generating skills deleted every pending skill in its scope, including Agent Skills Finder suggestions awaiting approval and manual pending ones. Skills now record their source (manual, llm, finder) and generation only replaces its own pending llm suggestions. activeSkillsText also filters by organization, and the skill endpoints validate their bodies with DTOs. Intent capture was one switch for the whole workspace. Admins can now set each member to inherit, on or off (also in bulk, optionally erasing the intents already captured), and members can opt themselves out. Only contributing callers get the _intent parameter; callers without a member identity never contribute. Changes are logged as LEARNING_CHANGED. --- docs/knowledge-graph.md | 36 ++- .../migration.sql | 19 ++ packages/backend/prisma/schema.prisma | 11 + .../src/audit/security-event.service.ts | 5 + .../kg-learning.service.spec.ts | 163 ++++++++++++++ .../knowledge-graph/kg-learning.service.ts | 211 ++++++++++++++++++ .../knowledge-graph/kg-skill.service.spec.ts | 119 ++++++++++ .../src/knowledge-graph/kg-skill.service.ts | 17 +- .../src/knowledge-graph/kg.controller.ts | 178 +++++++++++++-- .../backend/src/knowledge-graph/kg.module.ts | 5 +- .../backend/src/knowledge-graph/kg.service.ts | 11 +- .../mcp-endpoint.controller.spec.ts | 19 +- .../src/mcp-server/mcp-endpoint.controller.ts | 9 +- .../mcp-servers.instructions.spec.ts | 41 +++- .../src/mcp-servers/mcp-servers.service.ts | 34 ++- packages/backend/src/users/users.service.ts | 2 + .../src/app/settings/organization/page.tsx | 2 +- packages/frontend/src/app/settings/page.tsx | 74 +++++- .../frontend/src/app/settings/users/page.tsx | 124 +++++++++- packages/frontend/src/lib/api.ts | 29 +++ 20 files changed, 1061 insertions(+), 48 deletions(-) create mode 100644 packages/backend/prisma/migrations/20261019100000_skill_source_member_learning/migration.sql create mode 100644 packages/backend/src/knowledge-graph/kg-learning.service.spec.ts create mode 100644 packages/backend/src/knowledge-graph/kg-learning.service.ts diff --git a/docs/knowledge-graph.md b/docs/knowledge-graph.md index 959365580..1135f7e22 100644 --- a/docs/knowledge-graph.md +++ b/docs/knowledge-graph.md @@ -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 @@ -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. @@ -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): diff --git a/packages/backend/prisma/migrations/20261019100000_skill_source_member_learning/migration.sql b/packages/backend/prisma/migrations/20261019100000_skill_source_member_learning/migration.sql new file mode 100644 index 000000000..589a347e1 --- /dev/null +++ b/packages/backend/prisma/migrations/20261019100000_skill_source_member_learning/migration.sql @@ -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: (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; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 1ef0abe06..4e24d5ff9 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -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]) @@ -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") diff --git a/packages/backend/src/audit/security-event.service.ts b/packages/backend/src/audit/security-event.service.ts index 3b65b5053..18920a9b4 100644 --- a/packages/backend/src/audit/security-event.service.ts +++ b/packages/backend/src/audit/security-event.service.ts @@ -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', diff --git a/packages/backend/src/knowledge-graph/kg-learning.service.spec.ts b/packages/backend/src/knowledge-graph/kg-learning.service.spec.ts new file mode 100644 index 000000000..9b0205317 --- /dev/null +++ b/packages/backend/src/knowledge-graph/kg-learning.service.spec.ts @@ -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, + ); + }); +}); diff --git a/packages/backend/src/knowledge-graph/kg-learning.service.ts b/packages/backend/src/knowledge-graph/kg-learning.service.ts new file mode 100644 index 000000000..906b012f7 --- /dev/null +++ b/packages/backend/src/knowledge-graph/kg-learning.service.ts @@ -0,0 +1,211 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../common/prisma.service'; +import { SecurityEventService, SecurityEvents } from '../audit/security-event.service'; +import { KgStaticService } from './kg-static.service'; + +export const LEARNING_MODES = ['inherit', 'on', 'off'] as const; +export type LearningMode = (typeof LEARNING_MODES)[number]; + +/** Workspace switch that members set to `inherit` follow. */ +export const LEARNING_DEFAULT_FLAG = 'kg_capture_intent'; + +/** Most members one bulk change may touch. */ +export const LEARNING_BULK_MAX = 500; + +export interface LearningActionContext { + actorUserId: string; + ip?: string; + userAgent?: string; +} + +export interface MemberLearning { + /** The workspace switch (`Capture user intent`). */ + workspaceDefault: boolean; + mode: LearningMode; + optOut: boolean; + /** Whether this member's requests are used for learning right now. */ + effective: boolean; +} + +/** + * The member's own opt-out wins; otherwise an admin's `on`/`off`, otherwise + * the workspace switch. + */ +export function effectiveLearning( + mode: string, + optOut: boolean, + workspaceDefault: boolean, +): boolean { + if (optOut) return false; + if (mode === 'on') return true; + if (mode === 'off') return false; + return workspaceDefault; +} + +/** + * Whether a caller may contribute to skill learning in a workspace: an active + * member whose effective setting is on. No user, a non-member (e.g. a + * `client:` token) or a deactivated member never contributes. + */ +export async function memberContributes( + prisma: PrismaService, + staticSvc: KgStaticService, + organizationId: string, + userId?: string | null, +): Promise { + if (!organizationId || !userId) return false; + const [workspaceDefault, member] = await Promise.all([ + staticSvc.getFlag(organizationId, LEARNING_DEFAULT_FLAG, false), + prisma.organizationMember.findUnique({ + where: { userId_organizationId: { userId, organizationId } }, + select: { learningMode: true, learningOptOut: true, deactivatedAt: true }, + }), + ]); + if (!member || member.deactivatedAt) return false; + return effectiveLearning(member.learningMode, member.learningOptOut, workspaceDefault); +} + +/** + * Who may contribute to skill learning. A contributing caller's tools carry + * the optional `_intent` parameter and their intents are stored; for anyone + * else the parameter is not offered and nothing is captured. + * + * Settings live on the membership, so the same person can differ between + * workspaces (Cloud) and a caller without a member identity (static instance + * credentials, client-credentials tokens) never contributes. + */ +@Injectable() +export class KgLearningService { + constructor( + private readonly prisma: PrismaService, + private readonly staticSvc: KgStaticService, + private readonly securityEvents: SecurityEventService, + ) {} + + workspaceDefault(organizationId: string): Promise { + return this.staticSvc.getFlag(organizationId, LEARNING_DEFAULT_FLAG, false); + } + + /** Whether this caller's tool calls may capture intent. Fails closed. */ + canContribute(organizationId: string, userId?: string | null): Promise { + return memberContributes(this.prisma, this.staticSvc, organizationId, userId); + } + + async getForMember(organizationId: string, userId: string): Promise { + const [workspaceDefault, member] = await Promise.all([ + this.workspaceDefault(organizationId), + this.prisma.organizationMember.findUnique({ + where: { userId_organizationId: { userId, organizationId } }, + select: { learningMode: true, learningOptOut: true }, + }), + ]); + if (!member) throw new NotFoundException('Member not found.'); + const mode = toMode(member.learningMode); + return { + workspaceDefault, + mode, + optOut: member.learningOptOut, + effective: effectiveLearning(mode, member.learningOptOut, workspaceDefault), + }; + } + + /** + * Admin: set the mode of one or more members of this workspace. Ids that + * are not members here are ignored (reported as `notFound`), never touched. + * `clearIntents` also erases the intents already captured from members + * whose learning ends up off. + */ + async setMode( + organizationId: string, + userIds: string[], + mode: LearningMode, + ctx: LearningActionContext, + opts?: { clearIntents?: boolean }, + ): Promise<{ updated: number; notFound: string[]; intentsCleared: number }> { + const ids = [...new Set(userIds)].slice(0, LEARNING_BULK_MAX); + const members = await this.prisma.organizationMember.findMany({ + where: { organizationId, userId: { in: ids } }, + select: { userId: true, learningMode: true, learningOptOut: true }, + }); + const found = new Set(members.map((m) => m.userId)); + const notFound = ids.filter((id) => !found.has(id)); + const changed = members.filter((m) => m.learningMode !== mode); + + if (changed.length > 0) { + await this.prisma.organizationMember.updateMany({ + where: { organizationId, userId: { in: changed.map((m) => m.userId) } }, + data: { learningMode: mode }, + }); + } + for (const m of changed) { + await this.securityEvents.log({ + event: SecurityEvents.LEARNING_CHANGED, + actorType: 'USER', + organizationId, + actorUserId: ctx.actorUserId, + targetUserId: m.userId, + metadata: { from: m.learningMode, to: mode, via: 'admin' }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + } + + let intentsCleared = 0; + if (opts?.clearIntents) { + const workspaceDefault = await this.workspaceDefault(organizationId); + const off = members + .filter((m) => !effectiveLearning(mode, m.learningOptOut, workspaceDefault)) + .map((m) => m.userId); + intentsCleared = await this.clearIntents(organizationId, off); + } + return { updated: changed.length, notFound, intentsCleared }; + } + + /** Member: opt out of (or back into) learning in this workspace. */ + async setOptOut( + organizationId: string, + userId: string, + optOut: boolean, + ctx: LearningActionContext, + opts?: { clearIntents?: boolean }, + ): Promise { + const member = await this.prisma.organizationMember.findUnique({ + where: { userId_organizationId: { userId, organizationId } }, + select: { learningOptOut: true }, + }); + if (!member) throw new NotFoundException('Member not found.'); + if (member.learningOptOut !== optOut) { + await this.prisma.organizationMember.update({ + where: { userId_organizationId: { userId, organizationId } }, + data: { learningOptOut: optOut }, + }); + await this.securityEvents.log({ + event: SecurityEvents.LEARNING_CHANGED, + actorType: 'USER', + organizationId, + actorUserId: ctx.actorUserId, + targetUserId: userId, + metadata: { optOut, via: 'self' }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + } + const intentsCleared = + optOut && opts?.clearIntents ? await this.clearIntents(organizationId, [userId]) : 0; + return { ...(await this.getForMember(organizationId, userId)), intentsCleared }; + } + + /** Erase captured intents of these members in this workspace only. */ + private async clearIntents(organizationId: string, userIds: string[]): Promise { + if (userIds.length === 0) return 0; + const res = await this.prisma.toolInvocation.updateMany({ + where: { organizationId, userId: { in: userIds }, intent: { not: null } }, + data: { intent: null }, + }); + return res.count; + } +} + +function toMode(value: string): LearningMode { + return (LEARNING_MODES as readonly string[]).includes(value) ? (value as LearningMode) : 'inherit'; +} diff --git a/packages/backend/src/knowledge-graph/kg-skill.service.spec.ts b/packages/backend/src/knowledge-graph/kg-skill.service.spec.ts index 02b2bdd1d..3e5dc0921 100644 --- a/packages/backend/src/knowledge-graph/kg-skill.service.spec.ts +++ b/packages/backend/src/knowledge-graph/kg-skill.service.spec.ts @@ -162,3 +162,122 @@ describe('KgSkillService custom skip', () => { expect(prisma.kgSkillSuggestion.deleteMany).not.toHaveBeenCalled(); }); }); + +/** + * Generation used to delete every pending skill in its scope, including + * Agent Skills Finder suggestions awaiting approval and manual pending ones. + * It now replaces only its own previous output (`source: 'llm'`). + */ +describe('KgSkillService provenance', () => { + const ORG = 'org-A'; + const realFetch = global.fetch; + + beforeEach(() => { + process.env.KG_LLM_BASE_URL = 'http://localhost:11434/v1'; + process.env.KG_LLM_MODEL = 'qwen2.5'; + delete process.env.KG_LLM_API_KEY; + global.fetch = jest.fn().mockResolvedValue( + okJson({ + choices: [ + { + message: { + content: JSON.stringify({ skills: [{ title: 'T', instruction: 'I', confidence: 0.5 }] }), + }, + }, + ], + }), + ); + }); + afterEach(() => { + global.fetch = realFetch; + delete process.env.KG_LLM_BASE_URL; + delete process.env.KG_LLM_MODEL; + }); + + it('server generation deletes only its own pending llm rows and tags new ones llm', async () => { + const prisma = { + mcpServerConfig: { + findUnique: jest.fn().mockResolvedValue({ id: 'srv', name: 'S', organizationId: ORG }), + }, + mcpServerConnector: { findMany: jest.fn().mockResolvedValue([]) }, + kgNode: { findMany: jest.fn().mockResolvedValue([]) }, + toolInvocation: { findMany: jest.fn().mockResolvedValue([]) }, + orgSettings: { findUnique: jest.fn().mockResolvedValue(null) }, + kgSkillSuggestion: { deleteMany: jest.fn(), create: jest.fn() }, + }; + const svc = new KgSkillService(prisma as any, { isEnabled: async () => true } as any); + await svc.generate(ORG, { mcpServerId: 'srv' }); + + expect(prisma.kgSkillSuggestion.deleteMany).toHaveBeenCalledWith({ + where: { organizationId: ORG, status: 'pending', source: 'llm', mcpServerId: 'srv' }, + }); + expect(prisma.kgSkillSuggestion.create.mock.calls[0][0].data).toEqual( + expect.objectContaining({ source: 'llm', status: 'pending', mcpServerId: 'srv' }), + ); + }); + + it('connector generation deletes only its own pending llm rows', async () => { + const prisma = { + toolInvocation: { + findMany: jest.fn().mockResolvedValue([ + { intent: 'x', status: 'SUCCESS', tool: { name: 't', connector: { name: 'C' } } }, + ]), + }, + connector: { findMany: jest.fn().mockResolvedValue([]) }, + orgSettings: { findUnique: jest.fn().mockResolvedValue(null) }, + kgSkillSuggestion: { deleteMany: jest.fn(), create: jest.fn() }, + }; + const svc = new KgSkillService(prisma as any, { isEnabled: async () => true } as any); + await svc.generate(ORG); + + expect(prisma.kgSkillSuggestion.deleteMany).toHaveBeenCalledWith({ + where: { organizationId: ORG, status: 'pending', source: 'llm', mcpServerId: null }, + }); + }); +}); + +describe('KgSkillService.create source', () => { + const ORG = 'org-A'; + + function prismaFor() { + return { + mcpServerConfig: { findUnique: jest.fn().mockResolvedValue({ organizationId: ORG }) }, + kgSkillSuggestion: { create: jest.fn().mockResolvedValue({ id: 's1' }) }, + }; + } + + it('records manual by default', async () => { + const prisma = prismaFor(); + await new KgSkillService(prisma as any, {} as any).create(ORG, { + title: 'T', + instruction: 'I', + mcpServerId: 'srv', + }); + expect(prisma.kgSkillSuggestion.create.mock.calls[0][0].data.source).toBe('manual'); + }); + + it('records finder when the Skills Finder files it', async () => { + const prisma = prismaFor(); + await new KgSkillService(prisma as any, {} as any).create(ORG, { + title: 'T', + instruction: 'I', + mcpServerId: 'srv', + status: 'pending', + source: 'finder', + }); + expect(prisma.kgSkillSuggestion.create.mock.calls[0][0].data).toEqual( + expect.objectContaining({ source: 'finder', status: 'pending' }), + ); + }); +}); + +describe('KgSkillService.activeSkillsText', () => { + it('filters by organization as well as by the given ids', async () => { + const findMany = jest.fn().mockResolvedValue([]); + const svc = new KgSkillService({ kgSkillSuggestion: { findMany } } as any, {} as any); + await svc.activeSkillsText('org-A', 'srv-1', ['c-1']); + expect(findMany.mock.calls[0][0].where).toEqual( + expect.objectContaining({ organizationId: 'org-A', status: 'applied' }), + ); + }); +}); diff --git a/packages/backend/src/knowledge-graph/kg-skill.service.ts b/packages/backend/src/knowledge-graph/kg-skill.service.ts index 6acc867f1..6430cc4ef 100644 --- a/packages/backend/src/knowledge-graph/kg-skill.service.ts +++ b/packages/backend/src/knowledge-graph/kg-skill.service.ts @@ -6,6 +6,9 @@ import { maybeRedactIntent } from './redact'; const MAX_INTENTS = 200; +/** Where a skill came from; generation only ever replaces its own `llm` rows. */ +export type SkillSource = 'manual' | 'llm' | 'finder'; + /** Auto-apply a generated skill only when at/above this confidence. */ export const SKILL_AUTO_APPLY_MIN = 0.9; @@ -129,8 +132,10 @@ export class KgSkillService { const idByName = new Map(connectors.map((c) => [c.name.toLowerCase(), c.id])); const autoApply = await this.autoApplyEnabled(organizationId); + // Replace only the previous generation's pending output: manual pending + // skills and Finder suggestions awaiting approval are not ours to drop. await this.prisma.kgSkillSuggestion.deleteMany({ - where: { organizationId, status: 'pending', mcpServerId: null }, + where: { organizationId, status: 'pending', source: 'llm', mcpServerId: null }, }); let created = 0; @@ -196,7 +201,7 @@ export class KgSkillService { const skills: any[] = Array.isArray(json?.skills) ? json.skills : []; await this.prisma.kgSkillSuggestion.deleteMany({ - where: { organizationId, status: 'pending', mcpServerId }, + where: { organizationId, status: 'pending', source: 'llm', mcpServerId }, }); const autoApply = await this.autoApplyEnabled(organizationId); @@ -232,6 +237,7 @@ export class KgSkillService { confidence, evidenceCount: Math.max(0, Math.min(9999, parseInt(s?.evidenceCount, 10) || 0)), status, + source: 'llm', }, }); return true; @@ -373,6 +379,7 @@ export class KgSkillService { confidence: Math.max(0, Math.min(1, Number(s?.confidence) || 0.8)), evidenceCount: 0, status: 'applied', + source: 'llm', }, }); n++; @@ -386,12 +393,16 @@ export class KgSkillService { /** Active skills composed into an MCP server's instructions at serve time. */ async activeSkillsText( + organizationId: string, serverId: string | string[], connectorIds: string[], ): Promise { const serverIds = Array.isArray(serverId) ? serverId : [serverId]; const skills = await this.prisma.kgSkillSuggestion.findMany({ where: { + // Re-stated rather than trusted from the ids, so a stray id can never + // pull another workspace's skills into these instructions. + organizationId, status: 'applied', OR: [ { mcpServerId: { in: serverIds.length ? serverIds : ['__none__'] } }, @@ -422,6 +433,7 @@ export class KgSkillService { connectorId?: string | null; mcpServerId?: string | null; status?: string; + source?: SkillSource; }, ) { const title = (body.title || '').trim().slice(0, 160); @@ -466,6 +478,7 @@ export class KgSkillService { confidence: 1, evidenceCount: 0, status, + source: body.source === 'finder' ? 'finder' : 'manual', }, }); } diff --git a/packages/backend/src/knowledge-graph/kg.controller.ts b/packages/backend/src/knowledge-graph/kg.controller.ts index 21bec3f78..7537305d6 100644 --- a/packages/backend/src/knowledge-graph/kg.controller.ts +++ b/packages/backend/src/knowledge-graph/kg.controller.ts @@ -12,10 +12,124 @@ import { UseGuards, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiProperty, ApiPropertyOptional, ApiTags } from '@nestjs/swagger'; +import { + ArrayMaxSize, + ArrayNotEmpty, + IsArray, + IsBoolean, + IsIn, + IsOptional, + IsString, +} from 'class-validator'; import { Roles, RolesGuard } from '../auth/roles.guard'; import { KgService } from './kg.service'; import { KgSkillService } from './kg-skill.service'; +import { + KgLearningService, + LEARNING_BULK_MAX, + LEARNING_MODES, + type LearningMode, +} from './kg-learning.service'; + +const SKILL_STATUSES = ['pending', 'applied', 'dismissed']; + +class CreateSkillDto { + @ApiProperty({ description: 'Short name of the skill (stored up to 160 characters).' }) + @IsString() + title: string; + + @ApiPropertyOptional({ description: 'When the model should apply it.' }) + @IsOptional() + @IsString() + whenToUse?: string; + + @ApiProperty({ description: 'The rule or procedure (stored up to 2000 characters).' }) + @IsString() + instruction: string; + + @ApiPropertyOptional({ description: 'Scope the skill to one connector of this workspace.' }) + @IsOptional() + @IsString() + connectorId?: string | null; + + @ApiPropertyOptional({ description: 'Scope the skill to one MCP server of this workspace (wins over connectorId).' }) + @IsOptional() + @IsString() + mcpServerId?: string | null; + + @ApiPropertyOptional({ enum: SKILL_STATUSES, description: 'Defaults to applied.' }) + @IsOptional() + @IsIn(SKILL_STATUSES) + status?: string; +} + +class UpdateSkillDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + title?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + whenToUse?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + instruction?: string; + + @ApiPropertyOptional({ enum: SKILL_STATUSES }) + @IsOptional() + @IsIn(SKILL_STATUSES) + status?: string; +} + +class SkillScopeDto { + @ApiPropertyOptional({ description: 'Work on one MCP server; omit for the connector-scoped skills.' }) + @IsOptional() + @IsString() + mcpServerId?: string; +} + +class SetMembersLearningDto { + @ApiProperty({ type: [String], description: `Member user ids of this workspace (max ${LEARNING_BULK_MAX}).` }) + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(LEARNING_BULK_MAX) + @IsString({ each: true }) + userIds: string[]; + + @ApiProperty({ + enum: LEARNING_MODES, + description: 'inherit = follow the workspace switch; on / off = override it for these members.', + }) + @IsIn(LEARNING_MODES as unknown as string[]) + mode: LearningMode; + + @ApiPropertyOptional({ + description: 'Also erase the intents already captured from members whose learning ends up off.', + default: false, + }) + @IsOptional() + @IsBoolean() + clearIntents?: boolean; +} + +class SetMyLearningDto { + @ApiProperty({ description: 'true = do not use my requests for skill learning in this workspace.' }) + @IsBoolean() + optOut: boolean; + + @ApiPropertyOptional({ + description: 'When opting out, also erase the intents already captured from me.', + default: false, + }) + @IsOptional() + @IsBoolean() + clearIntents?: boolean; +} @ApiTags('Knowledge Graph') @ApiBearerAuth() @@ -25,6 +139,7 @@ export class KgController { constructor( private readonly kg: KgService, private readonly skills: KgSkillService, + private readonly learning: KgLearningService, ) {} @Get() @@ -96,17 +211,9 @@ export class KgController { @ApiOperation({ summary: 'Create a skill manually (live for MCP by default)' }) async createSkill( @Req() req: any, - @Body() - body: { - title: string; - whenToUse?: string; - instruction: string; - connectorId?: string | null; - mcpServerId?: string | null; - status?: string; - }, + @Body() body: CreateSkillDto, ) { - return this.skills.create(req.user.organizationId, body); + return this.skills.create(req.user.organizationId, { ...body, source: 'manual' }); } @Post('skills/generate') @@ -115,7 +222,7 @@ export class KgController { summary: 'Generate skill suggestions from captured intents — per-connector, or server-wide when mcpServerId is given', }) - async generateSkills(@Req() req: any, @Body() body: { mcpServerId?: string }) { + async generateSkills(@Req() req: any, @Body() body: SkillScopeDto) { return this.skills.generate(req.user.organizationId, { mcpServerId: body?.mcpServerId }); } @@ -124,7 +231,7 @@ export class KgController { @ApiOperation({ summary: 'Merge the active skills in a scope into the fewest non-redundant ones (AI)', }) - async consolidateSkills(@Req() req: any, @Body() body: { mcpServerId?: string }) { + async consolidateSkills(@Req() req: any, @Body() body: SkillScopeDto) { return this.skills.consolidate(req.user.organizationId, { mcpServerId: body?.mcpServerId }); } @@ -148,11 +255,46 @@ export class KgController { async updateSkill( @Req() req: any, @Param('id') id: string, - @Body() body: { title?: string; whenToUse?: string; instruction?: string; status?: string }, + @Body() body: UpdateSkillDto, ) { return this.skills.update(req.user.organizationId, id, body); } + // ── Who contributes to skill learning ──────────────────────────────────── + + @Get('learning/me') + @ApiOperation({ summary: 'Whether my requests are used for skill learning in this workspace' }) + async getMyLearning(@Req() req: any) { + return this.learning.getForMember(req.user.organizationId, req.user.sub); + } + + @Put('learning/me') + @ApiOperation({ + summary: 'Opt out of (or back into) skill learning in this workspace. Cannot override an admin\'s "off".', + }) + async setMyLearning(@Req() req: any, @Body() body: SetMyLearningDto) { + return this.learning.setOptOut( + req.user.organizationId, + req.user.sub, + body.optOut, + this.actionContext(req), + { clearIntents: body.clearIntents }, + ); + } + + @Put('learning/members') + @Roles('ADMIN') + @ApiOperation({ summary: 'Set whether these members\' requests are used for skill learning (ADMIN)' }) + async setMembersLearning(@Req() req: any, @Body() body: SetMembersLearningDto) { + return this.learning.setMode( + req.user.organizationId, + body.userIds, + body.mode, + this.actionContext(req), + { clearIntents: body.clearIntents }, + ); + } + @Delete('skills/:id') @Roles('ADMIN') @ApiOperation({ summary: 'Delete a skill' }) @@ -229,4 +371,12 @@ export class KgController { async deleteNode(@Req() req: any, @Param('id') id: string) { return this.kg.deleteNode(req.user.organizationId, id); } + + private actionContext(req: any) { + return { + actorUserId: req.user.sub as string, + ip: req.ip as string | undefined, + userAgent: req.headers?.['user-agent'] as string | undefined, + }; + } } diff --git a/packages/backend/src/knowledge-graph/kg.module.ts b/packages/backend/src/knowledge-graph/kg.module.ts index c3933a704..5773f7d81 100644 --- a/packages/backend/src/knowledge-graph/kg.module.ts +++ b/packages/backend/src/knowledge-graph/kg.module.ts @@ -5,6 +5,7 @@ import { KgStaticService } from './kg-static.service'; import { KgObservationalService } from './kg-observational.service'; import { KgLlmService } from './kg-llm.service'; import { KgSkillService } from './kg-skill.service'; +import { KgLearningService } from './kg-learning.service'; /** * Knowledge Graph module. PrismaService, RedisService, DeploymentService and @@ -14,7 +15,7 @@ import { KgSkillService } from './kg-skill.service'; @Global() @Module({ controllers: [KgController], - providers: [KgService, KgStaticService, KgObservationalService, KgLlmService, KgSkillService], - exports: [KgService, KgStaticService, KgObservationalService, KgLlmService, KgSkillService], + providers: [KgService, KgStaticService, KgObservationalService, KgLlmService, KgSkillService, KgLearningService], + exports: [KgService, KgStaticService, KgObservationalService, KgLlmService, KgSkillService, KgLearningService], }) export class KgModule {} diff --git a/packages/backend/src/knowledge-graph/kg.service.ts b/packages/backend/src/knowledge-graph/kg.service.ts index 2ac20b6e8..9a37590b4 100644 --- a/packages/backend/src/knowledge-graph/kg.service.ts +++ b/packages/backend/src/knowledge-graph/kg.service.ts @@ -9,6 +9,7 @@ import { PrismaService } from '../common/prisma.service'; import { RedisService } from '../common/redis.service'; import { RolesService } from '../roles/roles.service'; import { KgStaticService } from './kg-static.service'; +import { memberContributes } from './kg-learning.service'; import { KgObservationalService } from './kg-observational.service'; import { KgLlmService } from './kg-llm.service'; @@ -128,9 +129,13 @@ export class KgService { } } - /** Whether intent capture is on for this org (used by the MCP path). */ - captureIntentEnabled(organizationId: string): Promise { - return this.staticSvc.getFlag(organizationId, 'kg_capture_intent', false); + /** + * Whether this caller's tool calls capture intent (used by the MCP path): + * the workspace switch, overridden per member by an admin and by the + * member's own opt-out. A caller without a member identity never does. + */ + captureIntentEnabled(organizationId: string, userId?: string | null): Promise { + return memberContributes(this.prisma, this.staticSvc, organizationId, userId); } /** diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts index 9270503e5..12fbe59d8 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.spec.ts @@ -747,6 +747,7 @@ describe('McpEndpointController — skills_save_to_workspace', () => { 'Source: https://github.com/bytedance/deer-flow/tree/8bb14fa/skills/public/code-documentation (licence: MIT)', mcpServerId: 'srv-A', status: 'pending', + source: 'finder', }); expect(JSON.parse(result.content[0].text)).toMatchObject({ saved: true, id: 'sk-1', status: 'pending' }); }); @@ -931,7 +932,7 @@ describe('McpEndpointController — resources and instructions follow the tool p ]; const kgSkills = { // A connector-scoped skill on the Fibu connector. - activeSkillsText: jest.fn(async (_srv: string, ids: string[]) => + activeSkillsText: jest.fn(async (_org: string, _srv: string, ids: string[]) => ids.includes('c-fibu') ? '## Workspace skills\n- FIBU-SKILL' : null, ), }; @@ -1047,6 +1048,22 @@ describe('McpEndpointController — resources and instructions follow the tool p await client.close(); }); + it('offers _intent only to a caller who contributes to learning, checked per member', async () => { + kgService.captureIntentEnabled.mockResolvedValue(false); + const off = await connect(null); + const offTools = (await off.listTools()).tools; + expect(offTools.length).toBeGreaterThan(0); + for (const t of offTools) expect(t.inputSchema.properties ?? {}).not.toHaveProperty('_intent'); + expect(kgService.captureIntentEnabled).toHaveBeenCalledWith(ORG_A, 'u-1'); + await off.close(); + + kgService.captureIntentEnabled.mockResolvedValue(true); + const on = await connect(null); + const onTools = (await on.listTools()).tools.filter((t) => t.name === 'crm_get'); + expect(onTools[0].inputSchema.properties).toHaveProperty('_intent'); + await on.close(); + }); + it('a caller who sees everything keeps the full instructions', async () => { const client = await connect(null); const instructions = client.getInstructions() ?? ''; diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index cbdfff499..6b06f5947 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -1094,9 +1094,13 @@ export class McpEndpointController { // Optional: ask the calling agent to pass the user's originating request on // every tool call, so we capture the intent/context behind it (used later to - // optimize the graph and suggest skills). Per-workspace switch, default off. + // optimize the graph and suggest skills). Workspace switch, default off, + // overridden per member; only callers who contribute get `_intent`. const captureIntent = invocationContext.organizationId - ? await this.kgService.captureIntentEnabled(invocationContext.organizationId) + ? await this.kgService.captureIntentEnabled( + invocationContext.organizationId, + invocationContext.userId, + ) : false; const kgEnabled = process.env.KG_MCP_TOOL !== 'off' && !!invocationContext.organizationId @@ -1748,6 +1752,7 @@ export class McpEndpointController { instruction: `${args.instruction.trim()}\n\nSource: ${args.sourceUrl} (licence: ${args.license || 'unknown'})`, mcpServerId: serverId, status: 'pending', + source: 'finder', }); const result = { saved: true, diff --git a/packages/backend/src/mcp-servers/mcp-servers.instructions.spec.ts b/packages/backend/src/mcp-servers/mcp-servers.instructions.spec.ts index 657bc9681..b23ddf44e 100644 --- a/packages/backend/src/mcp-servers/mcp-servers.instructions.spec.ts +++ b/packages/backend/src/mcp-servers/mcp-servers.instructions.spec.ts @@ -24,7 +24,7 @@ describe('McpServersService — caller-scoped instructions and resources', () => mcpResource: { findMany: jest.fn().mockResolvedValue(resourceRows) }, }; const kgSkills = { - activeSkillsText: jest.fn(async (_s: string, ids: string[]) => + activeSkillsText: jest.fn(async (_o: string, _s: string, ids: string[]) => ids.length ? `## Workspace skills\n- for ${ids.join(',')}` : '## Workspace skills\n- server-wide', ), }; @@ -46,7 +46,7 @@ describe('McpServersService — caller-scoped instructions and resources', () => expect(text).toContain('Use the CRM.'); expect(text).not.toContain('Ledger rules.'); expect(text).not.toContain('Fibu'); - expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('srv-1', ['c-crm']); + expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-A', 'srv-1', ['c-crm']); }); it('scopes the assignment query to the visible ids AND the server organization', async () => { @@ -64,7 +64,7 @@ describe('McpServersService — caller-scoped instructions and resources', () => const text = await service.getComposedInstructions('srv-1', []); expect(text).toBe('SERVER-GUIDANCE\n\n## Workspace skills\n- server-wide'); expect(prisma.mcpServerConnector.findMany).not.toHaveBeenCalled(); - expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('srv-1', []); + expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-A', 'srv-1', []); }); it('getVisibleContent returns the visible connectors with instructions and their stored rows', async () => { @@ -110,3 +110,38 @@ describe('McpServersService — caller-scoped instructions and resources', () => expect(prisma.mcpResource.findMany).not.toHaveBeenCalled(); }); }); + +/** + * A shared /mcp connection can reach connectors of several workspaces. Each + * workspace's skills are read with its own organization and only its own + * server and connector ids. + */ +describe('McpServersService — shared guide skills per workspace', () => { + it('reads each workspace\'s skills with its own organization and ids', async () => { + const prisma = { + mcpServerConfig: { + findMany: jest.fn().mockResolvedValue([ + { id: 'srv-a', name: 'A', instructions: null, organizationId: 'org-A' }, + ]), + }, + connector: { + findMany: jest.fn().mockResolvedValue([ + { id: 'c-a', name: 'CRM', instructions: null, organizationId: 'org-A' }, + { id: 'c-b', name: 'Fibu', instructions: null, organizationId: 'org-B' }, + ]), + }, + }; + const kgSkills = { + activeSkillsText: jest.fn(async (org: string) => `## Workspace skills\n- ${org}`), + }; + const service = new McpServersService(prisma as any, kgSkills as any, {} as any); + + const text = await service.getSharedGuide({ connectorIds: ['c-a', 'c-b'], serverIds: ['srv-a'] }); + + expect(kgSkills.activeSkillsText).toHaveBeenCalledTimes(2); + expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-A', ['srv-a'], ['c-a']); + expect(kgSkills.activeSkillsText).toHaveBeenCalledWith('org-B', [], ['c-b']); + expect(text).toContain('- org-A'); + expect(text).toContain('- org-B'); + }); +}); diff --git a/packages/backend/src/mcp-servers/mcp-servers.service.ts b/packages/backend/src/mcp-servers/mcp-servers.service.ts index bd280b22a..eb21cbaee 100644 --- a/packages/backend/src/mcp-servers/mcp-servers.service.ts +++ b/packages/backend/src/mcp-servers/mcp-servers.service.ts @@ -366,13 +366,15 @@ export class McpServersService { serverIds.length ? this.prisma.mcpServerConfig.findMany({ where: { id: { in: serverIds } }, - select: { name: true, instructions: true }, + select: { id: true, name: true, instructions: true, organizationId: true }, orderBy: { createdAt: 'asc' }, }) - : Promise.resolve([]), + : Promise.resolve( + [] as Array<{ id: string; name: string; instructions: string | null; organizationId: string }>, + ), this.prisma.connector.findMany({ where: { id: { in: connectorIds } }, - select: { name: true, instructions: true }, + select: { id: true, name: true, instructions: true, organizationId: true }, orderBy: { name: 'asc' }, }), ]); @@ -384,8 +386,17 @@ export class McpServersService { for (const c of connectors) { if (c.instructions) parts.push(`## ${c.name}\n${c.instructions}`); } - const skillsText = await this.kgSkills.activeSkillsText(serverIds, connectorIds); - if (skillsText) parts.push(skillsText); + // A shared connection can reach several workspaces: read each one's + // skills with its own organization and only its own server/connector ids. + const orgIds = [...new Set(connectors.map((c) => c.organizationId))]; + for (const orgId of orgIds) { + const skillsText = await this.kgSkills.activeSkillsText( + orgId, + servers.filter((s) => s.organizationId === orgId).map((s) => s.id), + connectors.filter((c) => c.organizationId === orgId).map((c) => c.id), + ); + if (skillsText) parts.push(skillsText); + } return parts.length > 0 ? parts.join('\n\n') : undefined; } @@ -500,7 +511,7 @@ export class McpServersService { private async composeInstructions( serverId: string, - server: { instructions: string | null } | null, + server: { instructions: string | null; organizationId: string } | null, serverConnectors: Array<{ connectorId: string; connector: { name: string; instructions: string | null }; @@ -521,10 +532,13 @@ export class McpServersService { // Compose applied skills (server-scoped + the visible connectors' // connector-scoped ones) dynamically, so editing/deleting a skill takes // effect immediately. - const skillsText = await this.kgSkills.activeSkillsText( - serverId, - serverConnectors.map((sc) => sc.connectorId), - ); + const skillsText = server + ? await this.kgSkills.activeSkillsText( + server.organizationId, + serverId, + serverConnectors.map((sc) => sc.connectorId), + ) + : null; if (skillsText) parts.push(skillsText); return parts.length > 0 ? parts.join('\n\n') : undefined; diff --git a/packages/backend/src/users/users.service.ts b/packages/backend/src/users/users.service.ts index 62dc1d636..4fd75ccf6 100644 --- a/packages/backend/src/users/users.service.ts +++ b/packages/backend/src/users/users.service.ts @@ -79,6 +79,8 @@ export class UsersService { joinedAt: m.joinedAt, deactivatedAt: m.deactivatedAt, active: m.deactivatedAt === null, + learningMode: m.learningMode, + learningOptOut: m.learningOptOut, })); } diff --git a/packages/frontend/src/app/settings/organization/page.tsx b/packages/frontend/src/app/settings/organization/page.tsx index cb64d75dc..6a7496c64 100644 --- a/packages/frontend/src/app/settings/organization/page.tsx +++ b/packages/frontend/src/app/settings/organization/page.tsx @@ -268,7 +268,7 @@ export default function OrganizationSettingsPage() { (null); const [marketingSaving, setMarketingSaving] = useState(false); + const [learning, setLearning] = useState(null); + const [learningSaving, setLearningSaving] = useState(false); // Delete account const [deleteOpen, setDeleteOpen] = useState(false); @@ -65,6 +67,35 @@ export default function SettingsPage() { .catch(() => setMarketingOptOut(null)); }, [isCloud, token]); + useEffect(() => { + if (!token) return; + knowledgeGraph.learning + .me(token) + .then(setLearning) + .catch(() => setLearning(null)); + }, [token]); + + const handleLearningOptOut = async (optOut: boolean, clearIntents = false) => { + if (!token) return; + setLearningSaving(true); + try { + const res = await knowledgeGraph.learning.setMine(token, { optOut, clearIntents }); + setLearning(res); + toast.show({ + tone: 'success', + title: clearIntents + ? `${res.intentsCleared} captured request(s) erased` + : optOut + ? 'Your requests are no longer used for skill learning' + : 'Your choice was removed; your admin\'s setting applies', + }); + } catch (err: any) { + toast.show({ tone: 'error', title: 'Could not save', description: err?.message }); + } finally { + setLearningSaving(false); + } + }; + const handleMarketingToggle = async (wantsEmails: boolean) => { if (!token) return; setMarketingSaving(true); @@ -272,6 +303,47 @@ export default function SettingsPage() { )} + {/* Skill learning: shown once it can concern this user (workspace switch + on, an admin turned it on for them, or they already opted out). */} + {learning && (learning.workspaceDefault || learning.mode === 'on' || learning.optOut) && ( + +

Skill learning

+

+ {learning.effective + ? 'Your requests to AI clients connected to this workspace are recorded with the tool calls they lead to, so admins can turn recurring needs into workspace skills.' + : learning.optOut + ? 'You opted out: your requests are not recorded for skill learning in this workspace.' + : 'Your admin has turned skill learning off for you in this workspace.'} +

+ + {learning.optOut && ( + + )} +
+ )} + {/* Change Password */}

Change Password

diff --git a/packages/frontend/src/app/settings/users/page.tsx b/packages/frontend/src/app/settings/users/page.tsx index dcb6fac27..c07e26466 100644 --- a/packages/frontend/src/app/settings/users/page.tsx +++ b/packages/frontend/src/app/settings/users/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; import * as Dialog from '@radix-ui/react-dialog'; import { useAuth } from '@/lib/auth-context'; -import { users, auth, roles } from '@/lib/api'; +import { users, auth, roles, knowledgeGraph, type LearningMode } from '@/lib/api'; import { AppSelect } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -14,6 +14,14 @@ import { EnterpriseNotice } from '@/components/enterprise-notice'; const ROLES = ['ADMIN', 'EDITOR', 'VIEWER'] as const; +function learningOptions(workspaceDefault: boolean) { + return [ + { value: 'inherit', label: `Default (${workspaceDefault ? 'on' : 'off'})` }, + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + ]; +} + function getInvitationStatus(invite: any): 'pending' | 'expired' { return new Date(invite.expiresAt) < new Date() ? 'expired' : 'pending'; } @@ -44,15 +52,23 @@ export default function SettingsUsersPage() { const [revoking, setRevoking] = useState(false); const [revokeError, setRevokeError] = useState(null); + // Skill learning: whose requests may feed AI skill suggestions. + const [learningDefault, setLearningDefault] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [eraseIntents, setEraseIntents] = useState(false); + const [learningSaving, setLearningSaving] = useState(false); + const loadData = async () => { if (!token) return; try { - const [userData, roleData, invitationData] = await Promise.all([ + const [userData, roleData, invitationData, kgSettings] = await Promise.all([ users.list(token), roles.list(token).catch(() => []), users.invitations(token).catch(() => []), + knowledgeGraph.getSettings(token).catch(() => null), ]); setUserList(userData); + setLearningDefault(!!kgSettings?.captureIntent); setRoleList(roleData); setInvitationList(invitationData); } catch (err: any) { @@ -79,6 +95,34 @@ export default function SettingsUsersPage() { } }; + const handleLearningChange = async (userIds: string[], mode: LearningMode) => { + if (!token || userIds.length === 0) return; + setLearningSaving(true); + try { + const clearIntents = mode !== 'on' && eraseIntents; + const result = await knowledgeGraph.learning.setMembers(token, { userIds, mode, clearIntents }); + setUserList((prev) => + prev.map((u) => (userIds.includes(u.id) ? { ...u, learningMode: mode } : u)), + ); + const parts = [`Skill learning updated for ${userIds.length} user${userIds.length !== 1 ? 's' : ''}.`]; + if (clearIntents) parts.push(`${result.intentsCleared} captured request(s) erased.`); + setMsg(parts.join(' ')); + } catch (err: any) { + setMsg(`Error: ${err.message}`); + } finally { + setLearningSaving(false); + } + }; + + const toggleSelected = (userId: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(userId)) next.delete(userId); + else next.add(userId); + return next; + }); + }; + const handleDeactivate = async (userId: string, email: string) => { if ( !token || @@ -362,18 +406,64 @@ export default function SettingsUsersPage() {
)} + {selected.size > 0 && ( + + + {selected.size} selected · Skill learning: + + {learningOptions(learningDefault).map((o) => ( + + ))} + + + + )} + {loading ? (

Loading...

) : ( - +
+ + @@ -381,6 +471,14 @@ export default function SettingsUsersPage() { {userList.map((u) => ( + + @@ -459,6 +573,7 @@ export default function SettingsUsersPage() { const status = getInvitationStatus(inv); return ( + @@ -476,6 +591,9 @@ export default function SettingsUsersPage() { +
+ 0 && selected.size === userList.length} + onChange={(e) => + setSelected(e.target.checked ? new Set(userList.map((u) => u.id)) : new Set()) + } + /> + Email Name Status Role MCP Role + Skill learning + Date Actions
+ toggleSelected(u.id)} + /> + {u.email} {u.id === currentUser?.id && ( @@ -422,6 +520,22 @@ export default function SettingsUsersPage() { )} +
+ handleLearningChange([u.id], v as LearningMode)} + className="h-8 whitespace-nowrap rounded-[9px] border border-[var(--border)] px-2 text-xs bg-[var(--surface)] text-[var(--text)]" + options={learningOptions(learningDefault)} + /> + {u.learningOptOut && ( + + opted out + + )} +
+
{new Date(u.createdAt).toLocaleDateString()}
{inv.email} — + Default + {new Date(inv.createdAt).toLocaleDateString()} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index bc2a5ba6e..570a671a1 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -980,6 +980,17 @@ export interface KgSettings { edgeAutoApply: boolean; } +export type LearningMode = 'inherit' | 'on' | 'off'; + +export interface MemberLearning { + /** The workspace switch ("Capture user intent"). */ + workspaceDefault: boolean; + mode: LearningMode; + optOut: boolean; + /** Whether this member's requests are used for skill learning right now. */ + effective: boolean; +} + export interface KgSkillList { items: KgSkill[]; total: number; @@ -996,6 +1007,24 @@ export const knowledgeGraph = { ), getSettings: (token: string) => request('/api/knowledge-graph/settings', { token }), + learning: { + me: (token: string) => + request('/api/knowledge-graph/learning/me', { token }), + setMine: (token: string, body: { optOut: boolean; clearIntents?: boolean }) => + request('/api/knowledge-graph/learning/me', { + token, + method: 'PUT', + body, + }), + setMembers: ( + token: string, + body: { userIds: string[]; mode: LearningMode; clearIntents?: boolean }, + ) => + request<{ updated: number; notFound: string[]; intentsCleared: number }>( + '/api/knowledge-graph/learning/members', + { token, method: 'PUT', body }, + ), + }, updateSettings: ( token: string, body: { From 35b5b552ad722c24d9892b498fff42793d45e7b3 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:13:47 +0200 Subject: [PATCH 2/2] Order the learning migration after main's 20261019120000 (#1145) --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/backend/prisma/migrations/{20261019100000_skill_source_member_learning => 20261019130000_skill_source_member_learning}/migration.sql (100%) diff --git a/packages/backend/prisma/migrations/20261019100000_skill_source_member_learning/migration.sql b/packages/backend/prisma/migrations/20261019130000_skill_source_member_learning/migration.sql similarity index 100% rename from packages/backend/prisma/migrations/20261019100000_skill_source_member_learning/migration.sql rename to packages/backend/prisma/migrations/20261019130000_skill_source_member_learning/migration.sql