diff --git a/apps/backend-agent-controller/project.json b/apps/backend-agent-controller/project.json index 53dfa6ff..4344d8fb 100644 --- a/apps/backend-agent-controller/project.json +++ b/apps/backend-agent-controller/project.json @@ -94,6 +94,12 @@ } } }, + "reindex-knowledge-embeddings": { + "executor": "nx:run-commands", + "options": { + "command": "npx ts-node -r tsconfig-paths/register --project apps/backend-agent-controller/tsconfig.app.json apps/backend-agent-controller/src/scripts/reindex-knowledge-embeddings.ts" + } + }, "test": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], diff --git a/apps/backend-agent-controller/src/migrations/1770900000000_AddKnowledgeEmbeddingsAndAutoEnrichment.ts b/apps/backend-agent-controller/src/migrations/1770900000000_AddKnowledgeEmbeddingsAndAutoEnrichment.ts new file mode 100644 index 00000000..f3b828af --- /dev/null +++ b/apps/backend-agent-controller/src/migrations/1770900000000_AddKnowledgeEmbeddingsAndAutoEnrichment.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddKnowledgeEmbeddingsAndAutoEnrichment1770900000000 implements MigrationInterface { + name = 'AddKnowledgeEmbeddingsAndAutoEnrichment1770900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS vector`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "knowledge_node_embeddings" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "client_id" uuid NOT NULL, + "knowledge_node_id" uuid NOT NULL, + "chunk_index" int NOT NULL, + "chunk_text" text NOT NULL, + "embedding" vector(768) NOT NULL, + "embedding_model" varchar(128) NOT NULL, + "embedding_provider" varchar(64) NOT NULL, + "content_hash" varchar(64) NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_knowledge_node_embeddings_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_knowledge_node_embeddings_client_id" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE, + CONSTRAINT "FK_knowledge_node_embeddings_node_id" FOREIGN KEY ("knowledge_node_id") REFERENCES "knowledge_nodes"("id") ON DELETE CASCADE, + CONSTRAINT "UQ_knowledge_node_embeddings_client_node_chunk" UNIQUE ("client_id", "knowledge_node_id", "chunk_index") + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_knowledge_node_embeddings_client_id" + ON "knowledge_node_embeddings" ("client_id") + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_knowledge_node_embeddings_node_id" + ON "knowledge_node_embeddings" ("knowledge_node_id") + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_knowledge_node_embeddings_content_hash" + ON "knowledge_node_embeddings" ("content_hash") + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_knowledge_node_embeddings_embedding_ivfflat" + ON "knowledge_node_embeddings" USING ivfflat ("embedding" vector_cosine_ops) + WITH (lists = 100) + `); + + await queryRunner.query(` + ALTER TABLE "ticket_automation" + ADD COLUMN IF NOT EXISTS "auto_enrichment_enabled" boolean NOT NULL DEFAULT true + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ticket_automation" DROP COLUMN IF EXISTS "auto_enrichment_enabled" + `); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_knowledge_node_embeddings_embedding_ivfflat"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_knowledge_node_embeddings_content_hash"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_knowledge_node_embeddings_node_id"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_knowledge_node_embeddings_client_id"`); + await queryRunner.query(`DROP TABLE IF EXISTS "knowledge_node_embeddings"`); + } +} diff --git a/apps/backend-agent-controller/src/migrations/1771000000000_AddAutoContextEnrichmentInteractionKind.ts b/apps/backend-agent-controller/src/migrations/1771000000000_AddAutoContextEnrichmentInteractionKind.ts new file mode 100644 index 00000000..6cfda22e --- /dev/null +++ b/apps/backend-agent-controller/src/migrations/1771000000000_AddAutoContextEnrichmentInteractionKind.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds auto_context_enrichment interaction kind for auto-enrichment telemetry. + */ +export class AddAutoContextEnrichmentInteractionKind1771000000000 implements MigrationInterface { + name = 'AddAutoContextEnrichmentInteractionKind1771000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "statistics_interaction_kind_enum" ADD VALUE IF NOT EXISTS 'auto_context_enrichment' + `); + } + + public async down(): Promise { + // PostgreSQL does not support removing enum values safely; keep value. + } +} diff --git a/apps/backend-agent-controller/src/scripts/reindex-knowledge-embeddings.ts b/apps/backend-agent-controller/src/scripts/reindex-knowledge-embeddings.ts new file mode 100644 index 00000000..417e7dbc --- /dev/null +++ b/apps/backend-agent-controller/src/scripts/reindex-knowledge-embeddings.ts @@ -0,0 +1,50 @@ +import { KnowledgeEmbeddingIndexService } from '@forepath/framework/backend'; +import { Logger } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; + +import { AppModule } from '../app/app.module'; + +function readClientIdArg(args: string[]): string | undefined { + const clientIdArg = args.find((arg) => arg.startsWith('--client-id=')); + + if (clientIdArg) { + return clientIdArg.split('=')[1]; + } + + const clientIdIdx = args.findIndex((arg) => arg === '--client-id'); + + if (clientIdIdx >= 0 && args[clientIdIdx + 1]) { + return args[clientIdIdx + 1]; + } + + return undefined; +} + +async function main(): Promise { + const logger = new Logger('ReindexKnowledgeEmbeddingsCli'); + const args = process.argv.slice(2); + const clientId = readClientIdArg(args); + + if (!clientId) { + throw new Error('Missing required argument --client-id '); + } + + const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn', 'log'] }); + + try { + const knowledgeEmbeddingIndexService = app.get(KnowledgeEmbeddingIndexService); + const result = await knowledgeEmbeddingIndexService.reindexAllPages(clientId); + + logger.log(`Reindexed ${result.processed} page embeddings for client ${clientId}`); + } finally { + await app.close(); + } +} + +main().catch((error: unknown) => { + const logger = new Logger('ReindexKnowledgeEmbeddingsCli'); + const message = error instanceof Error ? error.message : String(error); + + logger.error(`Embedding reindex failed: ${message}`); + process.exit(1); +}); diff --git a/apps/backend-agent-controller/src/typeorm.config.ts b/apps/backend-agent-controller/src/typeorm.config.ts index 18622dd5..20e4163c 100644 --- a/apps/backend-agent-controller/src/typeorm.config.ts +++ b/apps/backend-agent-controller/src/typeorm.config.ts @@ -14,6 +14,7 @@ import { StatisticsUserEntity, ClientAgentAutonomyEntity, KnowledgeNodeEntity, + KnowledgeNodeEmbeddingEntity, KnowledgePageActivityEntity, KnowledgeRelationEntity, TicketActivityEntity, @@ -68,6 +69,7 @@ export const typeormConfig: DataSourceOptions = { TicketAutomationRunStepEntity, ClientAgentAutonomyEntity, KnowledgeNodeEntity, + KnowledgeNodeEmbeddingEntity, KnowledgePageActivityEntity, KnowledgeRelationEntity, AgentConsoleRegexFilterRuleEntity, diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/sequence-auto-enrichment-pgvector.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-auto-enrichment-pgvector.mmd new file mode 100644 index 00000000..e324642c --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-auto-enrichment-pgvector.mmd @@ -0,0 +1,31 @@ +sequenceDiagram + participant UI as AgentConsoleChatUI + participant Ctl as ClientsGateway + participant Res as AutoContextResolver + participant Tix as TicketsService + participant KTree as KnowledgeTreeService + participant Vec as KnowledgeNodeEmbeddingsPgvector + participant Mgr as AgentManagerGateway + participant Comp as PromptContextComposer + participant Prov as AgentProvider + + UI->>Ctl: forward(event=chat, payload.contextInjection) + Ctl->>Ctl: normalize manual context (ticketShas, knowledgeShas) + alt autoEnrichmentEnabled=true and global feature enabled + Ctl->>Res: resolve(clientId, prompt, contextInjection) + Res->>Tix: resolveTicketIdByClientSha(manual ticketShas) + Res->>KTree: findNodeBySha(manual knowledgeShas) + Res->>Vec: topK chunks by cosine distance (client scoped) + Res->>KTree: relation context expansion (ticket/page sources) + Res->>Res: dedupe by canonical entity id + apply section/char budgets + Res-->>Ctl: merged contextInjection (manual + auto knowledgeContexts) + else auto enrichment disabled + Ctl->>Ctl: keep manual-only contextInjection + end + Ctl->>Mgr: emit chat with enriched contextInjection + Mgr->>Comp: composeChatMessage(message, contextInjection) + Comp-->>Mgr: hidden preamble + user message + Mgr->>Prov: sendMessage/sendMessageStream + Prov-->>Mgr: response payload + Mgr-->>Ctl: chatMessage/chatEvent + Ctl-->>UI: forwardAck + response events diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd index 0c5cdaae..5a5e162c 100644 --- a/libs/domains/framework/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd +++ b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-autonomous-ticket.mmd @@ -20,7 +20,7 @@ sequenceDiagram end Note over Orch,Vcs: Default strategy reuses `automation/ticket-{ticketId}`; `new_per_run` or one-shot force uses ephemeral `automation/{runPrefix}`. loop maxIterations - Orch->>Chat: sendChatSync(sync, ephemeral) + Orch->>Chat: sendChatSync(sync, ephemeral, contextInjection) Chat->>Mgr: chat Mgr->>Prov: sendMessage(continue, resumeSessionSuffix) Prov-->>Mgr: NDJSON / text @@ -28,6 +28,7 @@ sequenceDiagram Chat-->>Orch: assistant text Orch->>DB: append run step end + Note over Orch,Chat: contextInjection carries includeWorkspace, environmentIds, and autoEnrichmentEnabled. Orch->>Vcs: runVerifierCommands Orch->>Vcs: getStatus; if dirty: stage all Orch->>Chat: sendChatSync (conventional commit subject; autonomous_ticket_commit_message) diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml index 1339bcbb..62759091 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml @@ -464,7 +464,7 @@ components: When event is "closeTerminal", the payload should contain {sessionId: string}. When event is "enhanceChat", the payload should contain { message: string, correlationId: string, model?: string }. When event is "generateTicketBody", the payload should contain { title: string, correlationId: string, model?: string }. - When event is "chat", the payload may include { message, correlationId?, model?, responseMode?: "single"|"stream"|"sync", ephemeral?: boolean, continue?: boolean, resumeSessionSuffix?: string, contextInjection?: { includeWorkspace?: boolean, environmentIds?: string[] } }. + When event is "chat", the payload may include { message, correlationId?, model?, responseMode?: "single"|"stream"|"sync", ephemeral?: boolean, continue?: boolean, resumeSessionSuffix?: string, contextInjection?: { includeWorkspace?: boolean, environmentIds?: string[], ticketShas?: string[], knowledgeShas?: string[], autoEnrichmentEnabled?: boolean } }. Response events forwarded from the agent-manager include "containerStats" (payload has status.running and optional stats). agentId: type: string diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml index 6536d832..6022183b 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml @@ -3064,6 +3064,40 @@ paths: $ref: '#/components/schemas/StatisticsSummaryDto' '403': description: Access denied to this client + /clients/{id}/knowledge/embeddings/reindex: + post: + summary: Manually reindex workspace knowledge embeddings + operationId: reindexClientKnowledgeEmbeddings + description: | + Triggers a synchronous embedding backfill for all knowledge pages in the workspace. + Requires workspace management permissions. + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: The UUID of the client + responses: + '200': + description: Reindex completed + content: + application/json: + schema: + type: object + required: [status, clientId, pagesReindexed] + properties: + status: + type: string + enum: [started] + clientId: + type: string + format: uuid + pagesReindexed: + type: integer + '403': + description: Access denied to this client /clients/{id}/statistics/chat-io: get: summary: Get chat I/O records for a client @@ -3099,7 +3133,16 @@ paths: name: interactionKind schema: type: string - enum: [chat, prompt_enhancement] + enum: + [ + chat, + prompt_enhancement, + ticket_body_generation, + auto_context_enrichment, + autonomous_ticket_run, + autonomous_ticket_run_turn, + autonomous_ticket_commit_message, + ] description: Filter by interaction type (normal chat vs prompt enhancement metrics) - in: query name: limit @@ -3363,7 +3406,16 @@ paths: name: interactionKind schema: type: string - enum: [chat, prompt_enhancement] + enum: + [ + chat, + prompt_enhancement, + ticket_body_generation, + auto_context_enrichment, + autonomous_ticket_run, + autonomous_ticket_run_turn, + autonomous_ticket_commit_message, + ] description: Filter by interaction type (normal chat vs prompt enhancement metrics) - in: query name: limit @@ -4424,6 +4476,9 @@ components: totalWords, totalChars, avgWordsPerMessage, + autoEnrichmentRuns, + autoEnrichmentContexts, + autoEnrichmentChars, filterDropCount, filterTypesBreakdown, ] @@ -4436,6 +4491,15 @@ components: type: integer avgWordsPerMessage: type: number + autoEnrichmentRuns: + type: integer + description: Number of turns where auto-enrichment injected at least one context section + autoEnrichmentContexts: + type: integer + description: Total number of auto-injected context sections + autoEnrichmentChars: + type: integer + description: Total character volume of auto-injected context sections filterDropCount: type: integer filterTypesBreakdown: @@ -4518,7 +4582,16 @@ components: enum: [input, output] interactionKind: type: string - enum: [chat, prompt_enhancement] + enum: + [ + chat, + prompt_enhancement, + ticket_body_generation, + auto_context_enrichment, + autonomous_ticket_run, + autonomous_ticket_run_turn, + autonomous_ticket_commit_message, + ] description: Normal chat vs prompt enhancement (magic wand) metrics wordCount: type: integer @@ -5014,6 +5087,8 @@ components: items: type: string format: uuid + autoEnrichmentEnabled: + type: boolean verifierProfile: $ref: '#/components/schemas/TicketVerifierProfileDto' requiresApproval: @@ -5040,6 +5115,7 @@ components: - allowedAgentIds - includeWorkspaceContext - contextEnvironmentIds + - autoEnrichmentEnabled - verifierProfile - requiresApproval - approvedAt @@ -5070,6 +5146,8 @@ components: items: type: string format: uuid + autoEnrichmentEnabled: + type: boolean verifierProfile: oneOf: - $ref: '#/components/schemas/TicketVerifierProfileDto' @@ -5286,6 +5364,16 @@ components: type: array items: type: string + ticketShas: + type: array + items: + type: string + knowledgeShas: + type: array + items: + type: string + autoEnrichmentEnabled: + type: boolean TicketAutomationRunChatEventDto: type: object description: > diff --git a/libs/domains/framework/backend/feature-agent-controller/src/index.ts b/libs/domains/framework/backend/feature-agent-controller/src/index.ts index a02c6b71..57af7ab9 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/index.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/index.ts @@ -72,6 +72,7 @@ export * from './lib/entities/agent-console-regex-filter-rule-client.entity'; export * from './lib/entities/agent-console-regex-filter-rule-sync-target.entity'; export * from './lib/entities/agent-console-regex-filter-rule.entity'; export * from './lib/entities/client-agent-autonomy.entity'; +export * from './lib/entities/knowledge-node-embedding.entity'; export * from './lib/entities/knowledge-node.entity'; export * from './lib/entities/knowledge-page-activity.entity'; export * from './lib/entities/knowledge-relation.entity'; @@ -115,6 +116,9 @@ export * from './lib/services/filter-rules-sync.scheduler'; export * from './lib/services/autonomous-ticket.scheduler'; export * from './lib/services/client-agent-autonomy.service'; export * from './lib/services/client-agent-proxy.service'; +export * from './lib/services/auto-context-resolver.service'; +export * from './lib/services/embeddings/knowledge-embedding-index.service'; +export * from './lib/services/embeddings/local-embedding.provider'; export * from './lib/services/remote-agents-session.service'; export * from './lib/services/ticket-automation.service'; export * from './lib/services/clients.service'; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts index 0ce50e58..61e33dd0 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts @@ -39,6 +39,7 @@ import { ClientAgentEnvironmentVariablesProxyService } from '../services/client- import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; import { ClientsService } from '../services/clients.service'; +import { KnowledgeEmbeddingIndexService } from '../services/embeddings/knowledge-embedding-index.service'; import { ProvisioningService } from '../services/provisioning.service'; import { ClientsController } from './clients.controller'; @@ -125,6 +126,9 @@ describe('ClientsController', () => { deleteProvisionedServer: jest.fn(), getServerInfo: jest.fn(), }; + const mockKnowledgeEmbeddingIndexService = { + reindexAllPages: jest.fn(), + }; const mockProvisioningProviderFactory = { getAllProviders: jest.fn(), hasProvider: jest.fn(), @@ -168,6 +172,10 @@ describe('ClientsController', () => { provide: ProvisioningService, useValue: mockProvisioningService, }, + { + provide: KnowledgeEmbeddingIndexService, + useValue: mockKnowledgeEmbeddingIndexService, + }, { provide: ProvisioningProviderFactory, useValue: mockProvisioningProviderFactory, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts index 57f05380..c53a28bf 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts @@ -58,6 +58,7 @@ import { ClientAgentEnvironmentVariablesProxyService } from '../services/client- import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; import { ClientsService } from '../services/clients.service'; +import { KnowledgeEmbeddingIndexService } from '../services/embeddings/knowledge-embedding-index.service'; import { ProvisioningService } from '../services/provisioning.service'; /** @@ -72,6 +73,7 @@ export class ClientsController { private readonly clientAgentFileSystemProxyService: ClientAgentFileSystemProxyService, private readonly clientAgentEnvironmentVariablesProxyService: ClientAgentEnvironmentVariablesProxyService, private readonly provisioningService: ProvisioningService, + private readonly knowledgeEmbeddingIndexService: KnowledgeEmbeddingIndexService, private readonly provisioningProviderFactory: ProvisioningProviderFactory, private readonly clientUsersService: ClientUsersService, private readonly clientsRepository: ClientsRepository, @@ -354,6 +356,25 @@ export class ClientsController { return await this.clientsService.findOne(id, userInfo.userId, userInfo.userRole, userInfo.isApiKeyAuth); } + /** + * Trigger a manual embeddings backfill for all knowledge pages in this workspace. + * Requires workspace management permissions. + */ + @Post(':id/knowledge/embeddings/reindex') + async reindexKnowledgeEmbeddings( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Req() req?: RequestWithUser, + ): Promise<{ status: 'started'; clientId: string; pagesReindexed: number }> { + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, id, req); + const result = await this.knowledgeEmbeddingIndexService.reindexAllPages(id); + + return { + status: 'started', + clientId: id, + pagesReindexed: result.processed, + }; + } + /** * Update an existing client. * Only accessible if the user has access to the client. diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/statistics/statistics-summary.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/statistics/statistics-summary.dto.ts index 442a6d77..f7a90a94 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/statistics/statistics-summary.dto.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/statistics/statistics-summary.dto.ts @@ -6,6 +6,9 @@ export class StatisticsSummaryDto { totalWords!: number; totalChars!: number; avgWordsPerMessage!: number; + autoEnrichmentRuns!: number; + autoEnrichmentContexts!: number; + autoEnrichmentChars!: number; filterDropCount!: number; filterTypesBreakdown!: { filterType: string; direction: string; count: number }[]; filterFlagCount!: number; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts index ee70197a..895e7336 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-response.dto.ts @@ -6,6 +6,7 @@ export class TicketAutomationResponseDto { allowedAgentIds!: string[]; includeWorkspaceContext!: boolean; contextEnvironmentIds!: string[]; + autoEnrichmentEnabled!: boolean; verifierProfile!: TicketVerifierProfileJson | null; requiresApproval!: boolean; approvedAt!: Date | null; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-chat-event.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-chat-event.dto.ts index 3bb76c03..576a8ec2 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-chat-event.dto.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/ticket-automation-run-chat-event.dto.ts @@ -27,6 +27,7 @@ export type TicketAutomationRunChatActionDto = TicketAutomationRunChatOpenAction export interface ContextInjectionDto { includeWorkspace?: boolean; environmentIds?: string[]; + autoEnrichmentEnabled?: boolean; } /** diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts index d4720912..b13c2c9f 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/ticket-automation/update-ticket-automation.dto.ts @@ -52,6 +52,10 @@ export class UpdateTicketAutomationDto { @IsUUID('4', { each: true }) contextEnvironmentIds?: string[]; + @IsOptional() + @IsBoolean() + autoEnrichmentEnabled?: boolean; + @IsOptional() @ValidateNested() @Type(() => TicketVerifierProfileDto) diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/knowledge-node-embedding.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/knowledge-node-embedding.entity.ts new file mode 100644 index 00000000..181976c2 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/knowledge-node-embedding.entity.ts @@ -0,0 +1,66 @@ +import { ClientEntity } from '@forepath/identity/backend'; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { KnowledgeNodeEntity } from './knowledge-node.entity'; + +@Entity('knowledge_node_embeddings') +@Index('IDX_knowledge_node_embeddings_client_id', ['clientId']) +@Index('IDX_knowledge_node_embeddings_node_id', ['knowledgeNodeId']) +@Index('IDX_knowledge_node_embeddings_client_node_chunk', ['clientId', 'knowledgeNodeId', 'chunkIndex'], { + unique: true, +}) +export class KnowledgeNodeEmbeddingEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'client_id' }) + clientId!: string; + + @ManyToOne(() => ClientEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'client_id' }) + client!: ClientEntity; + + @Column({ type: 'uuid', name: 'knowledge_node_id' }) + knowledgeNodeId!: string; + + @ManyToOne(() => KnowledgeNodeEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'knowledge_node_id' }) + knowledgeNode!: KnowledgeNodeEntity; + + @Column({ type: 'int', name: 'chunk_index' }) + chunkIndex!: number; + + @Column({ type: 'text', name: 'chunk_text' }) + chunkText!: string; + + /** + * pgvector column declared as "vector" to avoid coupling entity code to a fixed dimension. + * Dimension constraints are enforced by migration and provider configuration. + */ + @Column({ type: 'vector', name: 'embedding' }) + embedding!: number[]; + + @Column({ type: 'varchar', length: 128, name: 'embedding_model' }) + embeddingModel!: string; + + @Column({ type: 'varchar', length: 64, name: 'embedding_provider' }) + embeddingProvider!: string; + + @Column({ type: 'varchar', length: 64, name: 'content_hash' }) + contentHash!: string; + + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts index 5057f856..8532693f 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts @@ -13,6 +13,7 @@ export enum StatisticsInteractionKind { CHAT = 'chat', PROMPT_ENHANCEMENT = 'prompt_enhancement', TICKET_BODY_GENERATION = 'ticket_body_generation', + AUTO_CONTEXT_ENRICHMENT = 'auto_context_enrichment', AUTONOMOUS_TICKET_RUN = 'autonomous_ticket_run', AUTONOMOUS_TICKET_RUN_TURN = 'autonomous_ticket_run_turn', /** Ephemeral remote chat used only to propose a Conventional Commits subject before `git commit`. */ diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts index 542bb4ba..4c4fdbbb 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.spec.ts @@ -18,6 +18,7 @@ describe('TicketAutomationEntity', () => { entity.allowedAgentIds = ['agent-uuid']; entity.includeWorkspaceContext = true; entity.contextEnvironmentIds = ['context-agent-uuid']; + entity.autoEnrichmentEnabled = true; entity.verifierProfile = verifierProfile; entity.requiresApproval = true; entity.approvedAt = new Date(); @@ -34,6 +35,7 @@ describe('TicketAutomationEntity', () => { expect(entity.allowedAgentIds).toEqual(['agent-uuid']); expect(entity.includeWorkspaceContext).toBe(true); expect(entity.contextEnvironmentIds).toEqual(['context-agent-uuid']); + expect(entity.autoEnrichmentEnabled).toBe(true); expect(entity.verifierProfile).toEqual(verifierProfile); expect(entity.requiresApproval).toBe(true); expect(entity.approvedByUserId).toBe('user-uuid'); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts index c5d019b9..720949f8 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/ticket-automation.entity.ts @@ -41,6 +41,12 @@ export class TicketAutomationEntity { @Column({ type: 'jsonb', name: 'context_environment_ids', default: () => "'[]'" }) contextEnvironmentIds!: string[]; + /** + * Enables prompt-based auto enrichment for autonomous runs when true. + */ + @Column({ type: 'boolean', name: 'auto_enrichment_enabled', default: true }) + autoEnrichmentEnabled!: boolean; + @Column({ type: 'jsonb', name: 'verifier_profile', nullable: true }) verifierProfile?: TicketVerifierProfileJson | null; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts index 8d427a15..8d6a4e8a 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts @@ -8,6 +8,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { ClientsRepository } from '../repositories/clients.repository'; +import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; import { ClientsService } from '../services/clients.service'; import { KnowledgeTreeService } from '../services/knowledge-tree.service'; @@ -147,6 +148,9 @@ describe('ClientsGateway', () => { collectPromptContextsForSource: jest.fn().mockResolvedValue({ promptSections: [] }), findNodeBySha: jest.fn().mockResolvedValue(null), }; + const mockAutoContextResolverService = { + resolve: jest.fn().mockImplementation(async ({ contextInjection }) => contextInjection), + }; const createMockSocket = (id = 'socket-1', withUserInfo = true) => { const emitted: Record[] = []; const socket = { @@ -177,6 +181,7 @@ describe('ClientsGateway', () => { { provide: TicketAutomationChatSyncService, useValue: mockTicketAutomationChatSync }, { provide: TicketsService, useValue: mockTicketsService }, { provide: KnowledgeTreeService, useValue: mockKnowledgeTreeService }, + { provide: AutoContextResolverService, useValue: mockAutoContextResolverService }, ], }).compile(); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts index 6900b293..6fc67720 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts @@ -20,11 +20,11 @@ import { import { Server, Socket } from 'socket.io'; import type { Socket as ClientSocket } from 'socket.io-client'; -import { KnowledgeRelationSourceType } from '../entities/knowledge-node.enums'; import { FilterDropDirection } from '../entities/statistics-chat-filter-drop.entity'; import { FilterFlagDirection } from '../entities/statistics-chat-filter-flag.entity'; import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { ClientsRepository } from '../repositories/clients.repository'; +import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; import { ClientsService } from '../services/clients.service'; import { KnowledgeTreeService } from '../services/knowledge-tree.service'; @@ -52,6 +52,7 @@ interface ContextInjectionPayload { ticketContexts?: string[]; knowledgeShas?: string[]; knowledgeContexts?: string[]; + autoEnrichmentEnabled?: boolean; } /** @@ -108,6 +109,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat private readonly ticketAutomationChatSync: TicketAutomationChatSyncService, private readonly ticketsService: TicketsService, private readonly knowledgeTreeService: KnowledgeTreeService, + private readonly autoContextResolverService: AutoContextResolverService, ) {} afterInit(server: Server): void { @@ -1103,11 +1105,20 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat return payload; } + const promptForAutoContext = ( + (typed as { message?: string; title?: string }).message ?? + (typed as { message?: string; title?: string }).title ?? + '' + ).trim(); + const resolvedContextInjection = await this.autoContextResolverService.resolve({ + clientId, + prompt: promptForAutoContext, + contextInjection, + }); const ticketShas = Array.from( - new Set((contextInjection.ticketShas ?? []).map((sha) => sha.trim()).filter((sha) => sha.length > 0)), + new Set((resolvedContextInjection.ticketShas ?? []).map((sha) => sha.trim()).filter((sha) => sha.length > 0)), ); const ticketContexts: string[] = []; - const autoInjectedRelationContexts: string[] = []; for (const sha of ticketShas) { try { @@ -1116,18 +1127,6 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat if (prompt?.prompt) { ticketContexts.push(prompt.prompt); } - - const ticketId = await this.ticketsService.resolveTicketIdByClientSha(clientId, sha); - - if (ticketId) { - const related = await this.knowledgeTreeService.collectPromptContextsForSource( - clientId, - KnowledgeRelationSourceType.TICKET, - ticketId, - ); - - autoInjectedRelationContexts.push(...related.promptSections); - } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1136,7 +1135,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat } const knowledgeShas = Array.from( - new Set((contextInjection.knowledgeShas ?? []).map((sha) => sha.trim()).filter((sha) => sha.length > 0)), + new Set((resolvedContextInjection.knowledgeShas ?? []).map((sha) => sha.trim()).filter((sha) => sha.length > 0)), ); if (ticketShas.length === 0 && knowledgeShas.length === 0) { @@ -1153,22 +1152,6 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat ); knowledgeContexts = knowledgeContextResponse.promptSections; - - for (const sha of knowledgeShas) { - const node = await this.knowledgeTreeService.findNodeBySha(clientId, sha); - - if (!node || node.nodeType !== 'page') { - continue; - } - - const related = await this.knowledgeTreeService.collectPromptContextsForSource( - clientId, - KnowledgeRelationSourceType.PAGE, - node.id, - ); - - autoInjectedRelationContexts.push(...related.promptSections); - } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -1179,13 +1162,13 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat return { ...typed, contextInjection: { - ...contextInjection, + ...resolvedContextInjection, ticketShas, ticketContexts, knowledgeShas, knowledgeContexts: Array.from( new Set( - [...knowledgeContexts, ...autoInjectedRelationContexts] + [...knowledgeContexts, ...(resolvedContextInjection.knowledgeContexts ?? [])] .map((ctx) => ctx.trim()) .filter((ctx) => ctx.length > 0), ), diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts index e18bab9b..2186b2ce 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts @@ -17,6 +17,7 @@ import { KEYCLOAK_CONNECT_OPTIONS, KEYCLOAK_INSTANCE } from 'nest-keycloak-conne import { ClientsController } from '../controllers/clients.controller'; import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; +import { KnowledgeNodeEmbeddingEntity } from '../entities/knowledge-node-embedding.entity'; import { KnowledgeNodeEntity } from '../entities/knowledge-node.entity'; import { KnowledgePageActivityEntity } from '../entities/knowledge-page-activity.entity'; import { KnowledgeRelationEntity } from '../entities/knowledge-relation.entity'; @@ -151,6 +152,8 @@ describe('ClientsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(KnowledgeNodeEntity)) .useValue(mockRepository) + .overrideProvider(getRepositoryToken(KnowledgeNodeEmbeddingEntity)) + .useValue(mockRepository) .overrideProvider(getRepositoryToken(KnowledgeRelationEntity)) .useValue(mockRepository) .overrideProvider(getRepositoryToken(KnowledgePageActivityEntity)) diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts index 8156d5a3..cee79d7c 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -30,6 +30,7 @@ import { StatisticsController } from '../controllers/statistics.controller'; import { TicketAutomationController } from '../controllers/ticket-automation.controller'; import { TicketsController } from '../controllers/tickets.controller'; import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; +import { KnowledgeNodeEmbeddingEntity } from '../entities/knowledge-node-embedding.entity'; import { KnowledgeNodeEntity } from '../entities/knowledge-node.entity'; import { KnowledgePageActivityEntity } from '../entities/knowledge-page-activity.entity'; import { KnowledgeRelationEntity } from '../entities/knowledge-relation.entity'; @@ -50,6 +51,7 @@ import { HetznerProvider } from '../providers/hetzner.provider'; import { ProvisioningProviderFactory } from '../providers/provisioning-provider.factory'; import { ClientsRepository } from '../repositories/clients.repository'; import { ProvisioningReferencesRepository } from '../repositories/provisioning-references.repository'; +import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { AutonomousRunOrchestratorService } from '../services/autonomous-run-orchestrator.service'; import { AutonomousTicketScheduler } from '../services/autonomous-ticket.scheduler'; import { ClientAgentAutonomyService } from '../services/client-agent-autonomy.service'; @@ -61,6 +63,8 @@ import { ClientAgentVcsProxyService } from '../services/client-agent-vcs-proxy.s import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; import { ClientsService } from '../services/clients.service'; +import { KnowledgeEmbeddingIndexService } from '../services/embeddings/knowledge-embedding-index.service'; +import { LocalEmbeddingProvider } from '../services/embeddings/local-embedding.provider'; import { KnowledgeBoardRealtimeService } from '../services/knowledge-board-realtime.service'; import { KnowledgeTreeService } from '../services/knowledge-tree.service'; import { ProvisioningService } from '../services/provisioning.service'; @@ -98,6 +102,7 @@ const authMethod = getAuthenticationMethod(); TicketAutomationRunStepEntity, ClientAgentAutonomyEntity, KnowledgeNodeEntity, + KnowledgeNodeEmbeddingEntity, KnowledgePageActivityEntity, KnowledgeRelationEntity, ]), @@ -124,6 +129,9 @@ const authMethod = getAuthenticationMethod(); ClientsService, TicketsService, KnowledgeTreeService, + AutoContextResolverService, + KnowledgeEmbeddingIndexService, + LocalEmbeddingProvider, TicketAutomationService, ClientAgentAutonomyService, RemoteAgentsSessionService, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/auto-context-resolver.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/auto-context-resolver.service.ts new file mode 100644 index 00000000..2ab03db8 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/auto-context-resolver.service.ts @@ -0,0 +1,234 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { KnowledgeNodeEmbeddingEntity } from '../entities/knowledge-node-embedding.entity'; +import { KnowledgeRelationSourceType } from '../entities/knowledge-node.enums'; + +import { LocalEmbeddingProvider } from './embeddings/local-embedding.provider'; +import { KnowledgeTreeService } from './knowledge-tree.service'; +import { StatisticsService } from './statistics.service'; +import { TicketsService } from './tickets.service'; + +interface ContextInjectionPayload { + includeWorkspace?: boolean; + environmentIds?: string[]; + ticketShas?: string[]; + ticketContexts?: string[]; + knowledgeShas?: string[]; + knowledgeContexts?: string[]; + autoEnrichmentEnabled?: boolean; +} + +interface AutoContextResolveInput { + clientId: string; + prompt: string; + contextInjection: ContextInjectionPayload; +} + +@Injectable() +export class AutoContextResolverService { + private readonly logger = new Logger(AutoContextResolverService.name); + private readonly autoEnrichEnabledGlobally = process.env.AUTO_ENRICH_ENABLED_GLOBAL !== 'false'; + private readonly vectorEnabled = process.env.AUTO_ENRICH_VECTOR_ENABLED !== 'false'; + private readonly maxSections = parseInt(process.env.AUTO_ENRICH_MAX_SECTIONS || '6', 10); + private readonly maxChars = parseInt(process.env.AUTO_ENRICH_MAX_CHARS || '12000', 10); + private readonly vectorTopK = parseInt(process.env.AUTO_ENRICH_VECTOR_TOP_K || '20', 10); + + constructor( + @InjectRepository(KnowledgeNodeEmbeddingEntity) + private readonly embeddingRepo: Repository, + private readonly ticketsService: TicketsService, + private readonly knowledgeTreeService: KnowledgeTreeService, + private readonly localEmbeddingProvider: LocalEmbeddingProvider, + private readonly statisticsService: StatisticsService, + ) {} + + async resolve(input: AutoContextResolveInput): Promise { + const normalizedTicketShas = this.normalize(input.contextInjection.ticketShas); + const normalizedKnowledgeShas = this.normalize(input.contextInjection.knowledgeShas); + const autoEnabled = input.contextInjection.autoEnrichmentEnabled !== false; + + if (!this.autoEnrichEnabledGlobally || !autoEnabled) { + return { + ...input.contextInjection, + ticketShas: normalizedTicketShas, + knowledgeShas: normalizedKnowledgeShas, + }; + } + + const manualTicketIds = new Set(); + const manualKnowledgeNodeIds = new Set(); + + for (const sha of normalizedTicketShas) { + const ticketId = await this.ticketsService.resolveTicketIdByClientSha(input.clientId, sha); + + if (ticketId) { + manualTicketIds.add(ticketId); + } + } + + for (const sha of normalizedKnowledgeShas) { + const node = await this.knowledgeTreeService.findNodeBySha(input.clientId, sha); + + if (node) { + manualKnowledgeNodeIds.add(node.id); + } + } + + const vectorKnowledgeContexts = this.vectorEnabled + ? await this.resolveVectorKnowledgeContexts(input.clientId, input.prompt, manualKnowledgeNodeIds) + : []; + const relationContexts = await this.resolveRelationContexts( + input.clientId, + manualTicketIds, + manualKnowledgeNodeIds, + normalizedKnowledgeShas, + ); + const combined = this.applySectionBudget([...relationContexts, ...vectorKnowledgeContexts]); + const output = { + ...input.contextInjection, + ticketShas: normalizedTicketShas, + knowledgeShas: normalizedKnowledgeShas, + knowledgeContexts: this.normalize([...(input.contextInjection.knowledgeContexts ?? []), ...combined]), + }; + + this.logger.debug( + JSON.stringify({ + msg: 'auto_context_resolved', + clientId: input.clientId, + autoEnabled, + vectorEnabled: this.vectorEnabled, + manualTicketCount: manualTicketIds.size, + manualKnowledgeCount: manualKnowledgeNodeIds.size, + vectorContextCount: vectorKnowledgeContexts.length, + relationContextCount: relationContexts.length, + finalContextCount: output.knowledgeContexts?.length ?? 0, + }), + ); + + if ((output.knowledgeContexts?.length ?? 0) > 0) { + const totalChars = (output.knowledgeContexts ?? []).reduce((sum, ctx) => sum + ctx.length, 0); + const metricsAgentId = + output.environmentIds?.find((environmentId) => environmentId.trim().length > 0) ?? + '00000000-0000-0000-0000-000000000000'; + + this.statisticsService + .recordAutoContextEnrichment(input.clientId, metricsAgentId, output.knowledgeContexts?.length ?? 0, totalChars) + .catch(() => undefined); + } + + return output; + } + + private async resolveVectorKnowledgeContexts( + clientId: string, + prompt: string, + excludedKnowledgeNodeIds: Set, + ): Promise { + const trimmed = prompt.trim(); + + if (!trimmed) { + return []; + } + + try { + const vector = (await this.localEmbeddingProvider.embedMany([trimmed]))[0]?.vector; + + if (!vector || vector.length === 0) { + return []; + } + + const formattedVector = `[${vector.join(',')}]`; + const rows = await this.embeddingRepo + .createQueryBuilder('embedding') + .where('embedding.client_id = :clientId', { clientId }) + .orderBy('embedding.embedding <=> CAST(:vector AS vector)', 'ASC') + .setParameter('vector', formattedVector) + .take(this.vectorTopK) + .getMany(); + const contexts: string[] = []; + const seenNodes = new Set(); + + for (const row of rows) { + if (excludedKnowledgeNodeIds.has(row.knowledgeNodeId) || seenNodes.has(row.knowledgeNodeId)) { + continue; + } + + seenNodes.add(row.knowledgeNodeId); + contexts.push(`Knowledge Page Context:\n${row.chunkText}`.trim()); + } + + return contexts; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + this.logger.warn(`Vector auto-enrichment failed for client ${clientId}: ${message}`); + + return []; + } + } + + private async resolveRelationContexts( + clientId: string, + manualTicketIds: Set, + manualKnowledgeNodeIds: Set, + normalizedKnowledgeShas: string[], + ): Promise { + const sections: string[] = []; + + for (const ticketId of manualTicketIds) { + const related = await this.knowledgeTreeService.collectPromptContextsForSource( + clientId, + KnowledgeRelationSourceType.TICKET, + ticketId, + ); + + sections.push(...related.promptSections); + } + + for (const sha of normalizedKnowledgeShas) { + const node = await this.knowledgeTreeService.findNodeBySha(clientId, sha); + + if (!node || node.nodeType !== 'page' || manualKnowledgeNodeIds.has(node.id)) { + continue; + } + + const related = await this.knowledgeTreeService.collectPromptContextsForSource( + clientId, + KnowledgeRelationSourceType.PAGE, + node.id, + ); + + sections.push(...related.promptSections); + } + + return this.normalize(sections); + } + + private applySectionBudget(sections: string[]): string[] { + const accepted: string[] = []; + let totalChars = 0; + + for (const section of sections) { + if (accepted.length >= this.maxSections) { + break; + } + + const nextChars = totalChars + section.length; + + if (nextChars > this.maxChars) { + break; + } + + accepted.push(section); + totalChars = nextChars; + } + + return accepted; + } + + private normalize(items?: string[]): string[] { + return Array.from(new Set((items ?? []).map((item) => item.trim()).filter((item) => item.length > 0))); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts index 1b9f740d..63c4e6b8 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts @@ -289,6 +289,7 @@ export class AutonomousRunOrchestratorService { const contextInjection = { includeWorkspace: automation.includeWorkspaceContext !== false, environmentIds: [...new Set([agentId, ...(automation.contextEnvironmentIds ?? [])])], + autoEnrichmentEnabled: automation.autoEnrichmentEnabled !== false, }; if (autonomy.preImproveTicket) { @@ -421,7 +422,7 @@ export class AutonomousRunOrchestratorService { ticket: TicketEntity, agentId: string, stepIdx: number, - contextInjection: { includeWorkspace: boolean; environmentIds: string[] }, + contextInjection: { includeWorkspace: boolean; environmentIds: string[]; autoEnrichmentEnabled: boolean }, ): Promise<{ ok: true; nextStepIndex: number } | { ok: false }> { await this.runRepo.update(run.id, { phase: TicketAutomationRunPhase.FINALIZE }); const status = await this.vcsProxy.getStatus(ticket.clientId, agentId); @@ -475,7 +476,7 @@ export class AutonomousRunOrchestratorService { run: TicketAutomationRunEntity, ticket: TicketEntity, agentId: string, - contextInjection: { includeWorkspace: boolean; environmentIds: string[] }, + contextInjection: { includeWorkspace: boolean; environmentIds: string[]; autoEnrichmentEnabled: boolean }, ): Promise<{ message: string; source: 'ai' | 'fallback' }> { const timeoutMs = parseInt(process.env.REMOTE_AGENT_COMMIT_MESSAGE_TIMEOUT_MS || '120000', 10); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/embeddings/knowledge-embedding-index.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/embeddings/knowledge-embedding-index.service.ts new file mode 100644 index 00000000..dbd564da --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/embeddings/knowledge-embedding-index.service.ts @@ -0,0 +1,148 @@ +import { createHash } from 'crypto'; + +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { KnowledgeNodeEmbeddingEntity } from '../../entities/knowledge-node-embedding.entity'; +import { KnowledgeNodeEntity } from '../../entities/knowledge-node.entity'; +import { KnowledgeNodeType } from '../../entities/knowledge-node.enums'; + +import { LocalEmbeddingProvider } from './local-embedding.provider'; + +interface EmbeddingChunk { + index: number; + text: string; +} + +@Injectable() +export class KnowledgeEmbeddingIndexService { + private readonly logger = new Logger(KnowledgeEmbeddingIndexService.name); + private readonly chunkMaxChars = parseInt(process.env.EMBEDDING_CHUNK_MAX_CHARS || '1200', 10); + + constructor( + @InjectRepository(KnowledgeNodeEntity) + private readonly knowledgeNodeRepo: Repository, + @InjectRepository(KnowledgeNodeEmbeddingEntity) + private readonly embeddingRepo: Repository, + private readonly localEmbeddingProvider: LocalEmbeddingProvider, + ) {} + + async reindexPage(clientId: string, knowledgeNodeId: string, title: string, content: string): Promise { + const chunks = this.buildChunks(title, content); + + await this.embeddingRepo.delete({ knowledgeNodeId }); + + if (chunks.length === 0) { + return; + } + + try { + const embeddings = await this.localEmbeddingProvider.embedMany(chunks.map((chunk) => chunk.text)); + const rows = chunks.map((chunk, idx) => + this.embeddingRepo.create({ + clientId, + knowledgeNodeId, + chunkIndex: chunk.index, + chunkText: chunk.text, + embedding: embeddings[idx].vector, + embeddingModel: this.localEmbeddingProvider.getModelName(), + embeddingProvider: this.localEmbeddingProvider.getProviderName(), + contentHash: this.hashContent(chunk.text), + }), + ); + + await this.embeddingRepo.save(rows); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + this.logger.warn(`Failed to index embeddings for page ${knowledgeNodeId}: ${message}`); + } + } + + async deleteForNode(knowledgeNodeId: string): Promise { + await this.embeddingRepo.delete({ knowledgeNodeId }); + } + + async reindexAllPages(clientId?: string): Promise<{ processed: number }> { + const where = clientId + ? { clientId, nodeType: KnowledgeNodeType.PAGE } + : { + nodeType: KnowledgeNodeType.PAGE, + }; + const pages = await this.knowledgeNodeRepo.find({ + where, + select: ['id', 'clientId', 'title', 'content'], + order: { updatedAt: 'DESC' }, + }); + + for (const page of pages) { + await this.reindexPage(page.clientId, page.id, page.title, page.content ?? ''); + } + + return { processed: pages.length }; + } + + private buildChunks(title: string, content: string): EmbeddingChunk[] { + const merged = `${title.trim()}\n\n${(content || '').trim()}`.trim(); + + if (!merged) { + return []; + } + + const lines = merged + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const chunks: EmbeddingChunk[] = []; + let chunk = ''; + let idx = 0; + + for (const line of lines) { + const candidate = chunk.length === 0 ? line : `${chunk}\n${line}`; + + if (candidate.length <= this.chunkMaxChars) { + chunk = candidate; + continue; + } + + if (chunk.length > 0) { + chunks.push({ index: idx++, text: chunk }); + } + + if (line.length <= this.chunkMaxChars) { + chunk = line; + } else { + const parts = this.splitLongLine(line); + + for (let i = 0; i < parts.length - 1; i++) { + chunks.push({ index: idx++, text: parts[i] }); + } + + chunk = parts[parts.length - 1] ?? ''; + } + } + + if (chunk.length > 0) { + chunks.push({ index: idx, text: chunk }); + } + + return chunks; + } + + private splitLongLine(text: string): string[] { + const parts: string[] = []; + let offset = 0; + + while (offset < text.length) { + parts.push(text.slice(offset, offset + this.chunkMaxChars)); + offset += this.chunkMaxChars; + } + + return parts; + } + + private hashContent(value: string): string { + return createHash('sha256').update(value).digest('hex'); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/embeddings/local-embedding.provider.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/embeddings/local-embedding.provider.ts new file mode 100644 index 00000000..4363ad4f --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/embeddings/local-embedding.provider.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; + +export interface LocalEmbeddingResult { + vector: number[]; +} + +@Injectable() +export class LocalEmbeddingProvider { + private readonly dimensions: number; + + constructor() { + const parsed = parseInt(process.env.EMBEDDING_DIMENSIONS || '768', 10); + + this.dimensions = Number.isFinite(parsed) && parsed > 0 ? parsed : 768; + } + + getModelName(): string { + return process.env.EMBEDDING_MODEL || 'local-hash-embed-v1'; + } + + getProviderName(): string { + return 'local'; + } + + async embedMany(texts: string[]): Promise { + return texts.map((text) => ({ vector: this.embedText(text) })); + } + + private embedText(text: string): number[] { + const vector = new Array(this.dimensions).fill(0); + const normalized = text.toLowerCase(); + + for (let i = 0; i < normalized.length; i++) { + const code = normalized.charCodeAt(i); + const bucket = (code * 31 + i * 17) % this.dimensions; + + vector[bucket] += ((code % 67) + 1) / 67; + } + + const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)); + + if (norm === 0) { + return vector; + } + + return vector.map((value) => value / norm); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts index b3c38193..2968c1ab 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.spec.ts @@ -44,6 +44,10 @@ describe('KnowledgeTreeService', () => { const knowledgeBoardRealtime: any = { emitToClient: jest.fn(), }; + const knowledgeEmbeddingIndexService: any = { + reindexPage: jest.fn(), + deleteForNode: jest.fn(), + }; const service = new KnowledgeTreeService( nodeRepo, relationRepo, @@ -54,6 +58,7 @@ describe('KnowledgeTreeService', () => { ticketsService, ticketBoardRealtime, knowledgeBoardRealtime, + knowledgeEmbeddingIndexService, ); beforeEach(() => { diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts index 500606ff..47de59b1 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts @@ -37,6 +37,7 @@ import { KnowledgePageActivityEntity } from '../entities/knowledge-page-activity import { KnowledgeRelationEntity } from '../entities/knowledge-relation.entity'; import { ClientsRepository } from '../repositories/clients.repository'; +import { KnowledgeEmbeddingIndexService } from './embeddings/knowledge-embedding-index.service'; import { KNOWLEDGE_BOARD_EVENTS } from './knowledge-board-realtime.constants'; import { KnowledgeBoardRealtimeService } from './knowledge-board-realtime.service'; import { TICKETS_BOARD_EVENTS } from './ticket-board-realtime.constants'; @@ -58,6 +59,7 @@ export class KnowledgeTreeService { private readonly ticketsService: TicketsService, private readonly ticketBoardRealtime: TicketBoardRealtimeService, private readonly knowledgeBoardRealtime: KnowledgeBoardRealtimeService, + private readonly knowledgeEmbeddingIndexService: KnowledgeEmbeddingIndexService, ) {} private async assertClientAccess(clientId: string, req?: RequestWithUser): Promise { @@ -290,6 +292,7 @@ export class KnowledgeTreeService { this.emitKnowledgeTreeChanged(saved.clientId); if (saved.nodeType === KnowledgeNodeType.PAGE) { + await this.knowledgeEmbeddingIndexService.reindexPage(saved.clientId, saved.id, saved.title, saved.content ?? ''); await this.appendPageActivity(saved.id, saved.clientId, KnowledgeActionType.CREATED, { title: saved.title }, req); } @@ -379,6 +382,8 @@ export class KnowledgeTreeService { this.emitKnowledgeTreeChanged(saved.clientId); if (saved.nodeType === KnowledgeNodeType.PAGE) { + await this.knowledgeEmbeddingIndexService.reindexPage(saved.clientId, saved.id, saved.title, saved.content ?? ''); + if (before.parentId !== (saved.parentId ?? null)) { await this.appendPageActivity( saved.id, @@ -441,6 +446,7 @@ export class KnowledgeTreeService { } await this.knowledgeNodeRepo.delete(node.id); + await this.knowledgeEmbeddingIndexService.deleteForNode(node.id); this.emitKnowledgeTreeChanged(node.clientId); } @@ -491,6 +497,12 @@ export class KnowledgeTreeService { this.emitKnowledgeTreeChanged(seed.clientId); if (duplicated.nodeType === KnowledgeNodeType.PAGE) { + await this.knowledgeEmbeddingIndexService.reindexPage( + duplicated.clientId, + duplicated.id, + duplicated.title, + duplicated.content ?? '', + ); await this.appendPageActivity( duplicated.id, duplicated.clientId, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts index b5d6cb10..d3805ff2 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts @@ -23,6 +23,7 @@ export interface RemoteChatSyncParams { contextInjection?: { includeWorkspace?: boolean; environmentIds?: string[]; + autoEnrichmentEnabled?: boolean; }; } diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts index d6357ef3..8608111c 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics-query.service.ts @@ -43,6 +43,7 @@ const ALLOWED_INTERACTION_KINDS: ReadonlySet = new Set([ StatisticsInteractionKind.CHAT, StatisticsInteractionKind.PROMPT_ENHANCEMENT, StatisticsInteractionKind.TICKET_BODY_GENERATION, + StatisticsInteractionKind.AUTO_CONTEXT_ENRICHMENT, StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN, StatisticsInteractionKind.AUTONOMOUS_TICKET_RUN_TURN, StatisticsInteractionKind.AUTONOMOUS_TICKET_COMMIT_MESSAGE, @@ -82,6 +83,9 @@ export class StatisticsQueryService { totalWords: 0, totalChars: 0, avgWordsPerMessage: 0, + autoEnrichmentRuns: 0, + autoEnrichmentContexts: 0, + autoEnrichmentChars: 0, filterDropCount: 0, filterTypesBreakdown: [], filterFlagCount: 0, @@ -89,13 +93,19 @@ export class StatisticsQueryService { }; } - const [chatAgg, filterDropAgg, filterFlagAgg] = await Promise.all([ + const [chatAgg, autoEnrichmentAgg, filterDropAgg, filterFlagAgg] = await Promise.all([ this.statisticsRepository.queryChatIoAggregate({ statisticsClientIds: ids, from, to, groupBy: params.groupBy, }), + this.statisticsRepository.queryChatIoAggregate({ + statisticsClientIds: ids, + from, + to, + interactionKind: StatisticsInteractionKind.AUTO_CONTEXT_ENRICHMENT, + }), this.statisticsRepository.queryFilterDropsAggregate({ statisticsClientIds: ids, from, to }), this.statisticsRepository.queryFilterFlagsAggregate({ statisticsClientIds: ids, from, to }), ]); @@ -105,6 +115,9 @@ export class StatisticsQueryService { totalWords: chatAgg.totalWords, totalChars: chatAgg.totalChars, avgWordsPerMessage: chatAgg.avgWordsPerMessage, + autoEnrichmentRuns: autoEnrichmentAgg.totalMessages, + autoEnrichmentContexts: autoEnrichmentAgg.totalWords, + autoEnrichmentChars: autoEnrichmentAgg.totalChars, filterDropCount: filterDropAgg.filterDropCount, filterTypesBreakdown: filterDropAgg.filterTypesBreakdown, filterFlagCount: filterFlagAgg.filterFlagCount, @@ -127,6 +140,9 @@ export class StatisticsQueryService { totalWords: 0, totalChars: 0, avgWordsPerMessage: 0, + autoEnrichmentRuns: 0, + autoEnrichmentContexts: 0, + autoEnrichmentChars: 0, filterDropCount: 0, filterTypesBreakdown: [], filterFlagCount: 0, @@ -134,13 +150,19 @@ export class StatisticsQueryService { }; } - const [chatAgg, filterDropAgg, filterFlagAgg] = await Promise.all([ + const [chatAgg, autoEnrichmentAgg, filterDropAgg, filterFlagAgg] = await Promise.all([ this.statisticsRepository.queryChatIoAggregate({ statisticsClientIds: ids, from, to, groupBy: params.groupBy, }), + this.statisticsRepository.queryChatIoAggregate({ + statisticsClientIds: ids, + from, + to, + interactionKind: StatisticsInteractionKind.AUTO_CONTEXT_ENRICHMENT, + }), this.statisticsRepository.queryFilterDropsAggregate({ statisticsClientIds: ids, from, to }), this.statisticsRepository.queryFilterFlagsAggregate({ statisticsClientIds: ids, from, to }), ]); @@ -150,6 +172,9 @@ export class StatisticsQueryService { totalWords: chatAgg.totalWords, totalChars: chatAgg.totalChars, avgWordsPerMessage: chatAgg.avgWordsPerMessage, + autoEnrichmentRuns: autoEnrichmentAgg.totalMessages, + autoEnrichmentContexts: autoEnrichmentAgg.totalWords, + autoEnrichmentChars: autoEnrichmentAgg.totalChars, filterDropCount: filterDropAgg.filterDropCount, filterTypesBreakdown: filterDropAgg.filterTypesBreakdown, filterFlagCount: filterFlagAgg.filterFlagCount, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics.service.ts index aebb722b..b4af7006 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/statistics.service.ts @@ -88,6 +88,26 @@ export class StatisticsService { } } + /** + * Record auto-context enrichment application counts per turn. + * Stores injected section count in wordCount and combined character volume in charCount. + */ + async recordAutoContextEnrichment( + clientId: string, + agentId: string, + sectionsInjected: number, + charsInjected: number, + ): Promise { + await this.recordChatOutput( + clientId, + agentId, + sectionsInjected, + charsInjected, + undefined, + StatisticsInteractionKind.AUTO_CONTEXT_ENRICHMENT, + ); + } + /** * Record a chat message that was filtered/dropped. For outgoing drops, * wordCount/charCount may be 0 when not available from agent-manager. diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts index d9e0fb0e..b76f8e85 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.spec.ts @@ -124,6 +124,7 @@ describe('TicketAutomationService', () => { allowedAgentIds: [] as string[], includeWorkspaceContext: true, contextEnvironmentIds: [] as string[], + autoEnrichmentEnabled: true, verifierProfile: null as { commands: Array<{ cmd: string; cwd?: string }> } | null, requiresApproval: false, approvedAt: null as Date | null, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts index 03fe3051..56e94a51 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts @@ -51,6 +51,7 @@ const APPROVAL_RELEVANT_AUTOMATION_FIELDS = new Set([ 'allowedAgentIds', 'includeWorkspaceContext', 'contextEnvironmentIds', + 'autoEnrichmentEnabled', 'verifierProfile', 'requiresApproval', 'defaultBranchOverride', @@ -136,6 +137,7 @@ export class TicketAutomationService { allowedAgentIds: [], includeWorkspaceContext: true, contextEnvironmentIds: [], + autoEnrichmentEnabled: true, requiresApproval: false, automationBranchStrategy: DEFAULT_TICKET_AUTOMATION_BRANCH_STRATEGY, forceNewAutomationBranchNextRun: false, @@ -158,6 +160,7 @@ export class TicketAutomationService { allowedAgentIds: row.allowedAgentIds ?? [], includeWorkspaceContext: row.includeWorkspaceContext !== false, contextEnvironmentIds: sortUuidList(row.contextEnvironmentIds ?? []), + autoEnrichmentEnabled: row.autoEnrichmentEnabled !== false, verifierProfile: row.verifierProfile ?? null, requiresApproval: row.requiresApproval, approvedAt: row.approvedAt ?? null, @@ -194,6 +197,7 @@ export class TicketAutomationService { const prevAllowedSorted = sortUuidList(row.allowedAgentIds ?? []); const prevIncludeWorkspace = row.includeWorkspaceContext !== false; const prevContextEnvSorted = sortUuidList(row.contextEnvironmentIds ?? []); + const prevAutoEnrichmentEnabled = row.autoEnrichmentEnabled !== false; const prevVerifierJson = JSON.stringify(parseAndValidateVerifierProfile(row.verifierProfile ?? { commands: [] })); const prevDefaultBranch = normalizeDefaultBranch(row.defaultBranchOverride); const prevStrategy: TicketAutomationBranchStrategy = @@ -234,6 +238,11 @@ export class TicketAutomationService { } } + if (dto.autoEnrichmentEnabled !== undefined && dto.autoEnrichmentEnabled !== prevAutoEnrichmentEnabled) { + row.autoEnrichmentEnabled = dto.autoEnrichmentEnabled; + actuallyChanged.push('autoEnrichmentEnabled'); + } + if (dto.verifierProfile !== undefined) { const parsed = parseAndValidateVerifierProfile(dto.verifierProfile); const nextJson = JSON.stringify(parsed); @@ -305,6 +314,7 @@ export class TicketAutomationService { k === 'allowedAgentIds' || k === 'includeWorkspaceContext' || k === 'contextEnvironmentIds' || + k === 'autoEnrichmentEnabled' || k === 'verifierProfile' || k === 'defaultBranchOverride' || k === 'automationBranchStrategy' || diff --git a/libs/domains/framework/backend/feature-agent-manager/spec/asyncapi.yaml b/libs/domains/framework/backend/feature-agent-manager/spec/asyncapi.yaml index fc87f60f..292f2861 100644 --- a/libs/domains/framework/backend/feature-agent-manager/spec/asyncapi.yaml +++ b/libs/domains/framework/backend/feature-agent-manager/spec/asyncapi.yaml @@ -442,6 +442,9 @@ components: description: Optional knowledge page/folder SHA references for prompt enrichment. items: type: string + autoEnrichmentEnabled: + type: boolean + description: Enable prompt-based automatic enrichment (default true when omitted). EnhanceChat: name: EnhanceChat title: Prompt enhancement command @@ -477,6 +480,8 @@ components: type: array items: type: string + autoEnrichmentEnabled: + type: boolean ChatEnhanceResult: name: ChatEnhanceResult title: Prompt enhancement result (unicast) @@ -559,6 +564,8 @@ components: type: array items: type: string + autoEnrichmentEnabled: + type: boolean TicketBodyResult: name: TicketBodyResult title: Ticket body generation result (unicast) diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index 053f8f1a..d0ae05b3 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -72,6 +72,7 @@ interface ContextInjectionPayload { ticketContexts?: string[]; knowledgeShas?: string[]; knowledgeContexts?: string[]; + autoEnrichmentEnabled?: boolean; } interface ChatEnhanceSuccessData { @@ -491,6 +492,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { const toolCallId = `enrichment-${correlationId}`; const enrichmentArgs = { includeWorkspace: contextInjection.includeWorkspace === true, + autoEnrichmentEnabled: contextInjection.autoEnrichmentEnabled !== false, environmentIds: contextInjection.environmentIds ?? [], ticketShas: contextInjection.ticketShas ?? [], ticketContextCount: contextInjection.ticketContexts?.length ?? 0, @@ -513,6 +515,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { result: { applied: true, includeWorkspace: contextInjection.includeWorkspace === true, + autoEnrichmentEnabled: contextInjection.autoEnrichmentEnabled !== false, environmentIds: contextInjection.environmentIds ?? [], ticketShas: contextInjection.ticketShas ?? [], ticketContextCount: contextInjection.ticketContexts?.length ?? 0, @@ -667,6 +670,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { } const includeWorkspace = contextInjection.includeWorkspace === true; + const autoEnrichmentEnabled = contextInjection.autoEnrichmentEnabled !== false; const requestedIds = Array.from( new Set((contextInjection.environmentIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0)), ); @@ -699,6 +703,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { if ( !includeWorkspace && + !autoEnrichmentEnabled && allowedIds.length === 0 && ticketShas.length === 0 && ticketContexts.length === 0 && @@ -710,6 +715,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { return { includeWorkspace, + autoEnrichmentEnabled, environmentIds: allowedIds, ticketShas, ticketContexts, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts index d486c0a5..99c485d7 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/tickets.service.spec.ts @@ -139,6 +139,7 @@ describe('TicketsService', () => { allowedAgentIds: ['agent-1'], includeWorkspaceContext: true, contextEnvironmentIds: [], + autoEnrichmentEnabled: true, verifierProfile: null, requiresApproval: false, approvedAt: null, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts index 529253f0..8e197d7b 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts @@ -116,6 +116,7 @@ export interface ContextInjectionPayload { environmentIds?: string[]; ticketShas?: string[]; knowledgeShas?: string[]; + autoEnrichmentEnabled?: boolean; } /** diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts index 6b50732d..52a17603 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.effects.spec.ts @@ -49,6 +49,7 @@ describe('TicketAutomationEffects', () => { allowedAgentIds: [], includeWorkspaceContext: true, contextEnvironmentIds: [], + autoEnrichmentEnabled: true, verifierProfile: null, requiresApproval: true, approvedAt: null, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts index acc73c81..da527bff 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.reducer.spec.ts @@ -36,6 +36,7 @@ describe('ticketAutomationReducer', () => { allowedAgentIds: ['a1'], includeWorkspaceContext: true, contextEnvironmentIds: [], + autoEnrichmentEnabled: true, verifierProfile: null, requiresApproval: false, approvedAt: null, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts index c29baa3e..11c0d467 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/ticket-automation/ticket-automation.types.ts @@ -10,6 +10,7 @@ export interface UpdateTicketAutomationDto { allowedAgentIds?: string[]; includeWorkspaceContext?: boolean; contextEnvironmentIds?: string[]; + autoEnrichmentEnabled?: boolean; verifierProfile?: TicketVerifierProfileJson; requiresApproval?: boolean; defaultBranchOverride?: string | null; @@ -34,6 +35,7 @@ export interface TicketAutomationResponseDto { allowedAgentIds: string[]; includeWorkspaceContext: boolean; contextEnvironmentIds: string[]; + autoEnrichmentEnabled: boolean; verifierProfile: TicketVerifierProfileJson | null; requiresApproval: boolean; approvedAt: string | null; diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts index e564e220..39b04723 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/tickets/tickets.reducer.spec.ts @@ -482,6 +482,7 @@ describe('ticketsReducer', () => { allowedAgentIds: [] as string[], includeWorkspaceContext: true, contextEnvironmentIds: [] as string[], + autoEnrichmentEnabled: true, verifierProfile: null, requiresApproval: false, approvedAt: null, diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html index 047d9fb3..39b9af4d 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html @@ -1660,6 +1660,17 @@

Include workspace context +
+ + +

Select additional environments to enrich context more specifically. The active run environment is included automatically. diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts index 76f1cac5..3f26b37c 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/tickets/tickets-board.component.ts @@ -115,6 +115,10 @@ function automationDtoMatchesServerConfig(dto: UpdateTicketAutomationDto, cfg: T return false; } + if ((dto.autoEnrichmentEnabled ?? true) !== (cfg.autoEnrichmentEnabled !== false)) { + return false; + } + if (dto.requiresApproval !== cfg.requiresApproval) { return false; } @@ -505,6 +509,7 @@ export class TicketsBoardComponent implements OnInit, AfterViewInit { /** Sorted unique agent UUIDs allowed to run automation for this ticket. */ automationDraftAllowedAgentIds = signal([]); automationDraftIncludeWorkspaceContext = signal(true); + automationDraftAutoEnrichmentEnabled = signal(true); automationDraftContextEnvironmentIds = signal([]); automationDraftDefaultBranch = signal(''); automationDraftBranchStrategy = signal('reuse_per_ticket'); @@ -730,6 +735,7 @@ export class TicketsBoardComponent implements OnInit, AfterViewInit { this.automationDraftRequiresApproval.set(cfg.requiresApproval); this.automationDraftAllowedAgentIds.set(normalizeAllowedAgentIdList(cfg.allowedAgentIds)); this.automationDraftIncludeWorkspaceContext.set(cfg.includeWorkspaceContext !== false); + this.automationDraftAutoEnrichmentEnabled.set(cfg.autoEnrichmentEnabled !== false); this.automationDraftContextEnvironmentIds.set(normalizeAllowedAgentIdList(cfg.contextEnvironmentIds ?? [])); this.automationDraftDefaultBranch.set(cfg.defaultBranchOverride ?? ''); this.automationDraftBranchStrategy.set(cfg.automationBranchStrategy ?? 'reuse_per_ticket'); @@ -1927,6 +1933,7 @@ export class TicketsBoardComponent implements OnInit, AfterViewInit { requiresApproval: this.automationDraftRequiresApproval(), allowedAgentIds: this.automationDraftAllowedAgentIds(), includeWorkspaceContext: this.automationDraftIncludeWorkspaceContext(), + autoEnrichmentEnabled: this.automationDraftAutoEnrichmentEnabled(), contextEnvironmentIds: this.automationDraftContextEnvironmentIds(), defaultBranchOverride: branch.length > 0 ? branch : null, automationBranchStrategy: this.automationDraftBranchStrategy(), @@ -2826,6 +2833,7 @@ export class TicketsBoardComponent implements OnInit, AfterViewInit { ? { contextInjection: { includeWorkspaceContext: ticketAutomation.includeWorkspaceContext !== false, + autoEnrichmentEnabled: ticketAutomation.autoEnrichmentEnabled !== false, selectedEnvironmentContextIds: normalizeAllowedAgentIdList(ticketAutomation.contextEnvironmentIds), }, }