diff --git a/docs/knowledge-graph.md b/docs/knowledge-graph.md index 95936558..1135f7e2 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/20261019130000_skill_source_member_learning/migration.sql b/packages/backend/prisma/migrations/20261019130000_skill_source_member_learning/migration.sql new file mode 100644 index 00000000..589a347e --- /dev/null +++ b/packages/backend/prisma/migrations/20261019130000_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 1ef0abe0..4e24d5ff 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 3b65b505..18920a9b 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 00000000..9b020531 --- /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 00000000..906b012f --- /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 02b2bdd1..3e5dc092 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 6acc867f..6430cc4e 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 21bec3f7..7537305d 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 c3933a70..5773f7d8 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 2ac20b6e..9a37590b 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 9270503e..12fbe59d 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 cbdfff49..6b06f594 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 657bc968..b23ddf44 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 bd280b22..eb21cbae 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 0930487b..4b7fa8cc 100644 --- a/packages/backend/src/users/users.service.ts +++ b/packages/backend/src/users/users.service.ts @@ -86,6 +86,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 cb64d75d..6a7496c6 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 dcb6fac2..c07e2646 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 bad4c8b6..b7d7205d 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -992,6 +992,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; @@ -1008,6 +1019,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: {