plugin-sensitive-filter-xr 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sensitive Filter Middleware
2
2
 
3
- `plugin-sensitive-filter-xr` filters sensitive content for both input and output in two mutually exclusive modes:
3
+ `@xpert-ai/plugin-sensitive-filter` filters sensitive content for both input and output in two mutually exclusive modes:
4
4
 
5
5
  - `rule`: deterministic rules (`keyword` / `regex`)
6
6
  - `llm`: natural-language policy evaluation with rewrite-only enforcement
@@ -94,8 +94,4 @@ Current behavior:
94
94
 
95
95
  ## Validation Commands
96
96
 
97
- ```bash
98
- /Users/xr/Documents/code/xpert-plugins/xpertai/node_modules/.bin/tsc -p /Users/xr/Documents/code/xpert-plugins/xpertai/middlewares/sensitive-filter/tsconfig.lib.json --noEmit
99
- npx jest --runInBand src/lib/sensitiveFilter.spec.ts
100
- node /Users/xr/Documents/code/xpert-plugins/plugin-dev-harness/dist/index.js --workspace ./xpertai --plugin ./middlewares/sensitive-filter
101
- ```
97
+
@@ -1 +1 @@
1
- {"version":3,"file":"sensitiveFilter.d.ts","sourceRoot":"","sources":["../../src/lib/sensitiveFilter.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAa,oBAAoB,EAA8B,MAAM,kBAAkB,CAAA;AAGnG,OAAO,EACL,eAAe,EAGf,uBAAuB,EACvB,wBAAwB,EAEzB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAML,qBAAqB,EAKtB,MAAM,YAAY,CAAA;AAulBnB,qBAEa,yBAA0B,YAAW,wBAAwB,CAAC,qBAAqB,CAAC;IAE/F,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAY;IAEvC,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CA6NlC;IAEK,gBAAgB,CACpB,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,eAAe,CAAC;IAa3B,OAAO,CAAC,wBAAwB;IA6OhC,OAAO,CAAC,uBAAuB;CAkbhC;AAED,YAAY,EAAE,qBAAqB,EAAE,CAAA"}
1
+ {"version":3,"file":"sensitiveFilter.d.ts","sourceRoot":"","sources":["../../src/lib/sensitiveFilter.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAa,oBAAoB,EAA8B,MAAM,kBAAkB,CAAA;AAGnG,OAAO,EACL,eAAe,EAGf,uBAAuB,EACvB,wBAAwB,EAEzB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAML,qBAAqB,EAKtB,MAAM,YAAY,CAAA;AAoxBnB,qBAEa,yBAA0B,YAAW,wBAAwB,CAAC,qBAAqB,CAAC;IAE/F,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAY;IAEvC,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CA6NlC;IAEK,gBAAgB,CACpB,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,eAAe,CAAC;IAa3B,OAAO,CAAC,wBAAwB;IAgThC,OAAO,CAAC,uBAAuB;CA2fhC;AAED,YAAY,EAAE,qBAAqB,EAAE,CAAA"}
@@ -1,6 +1,8 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
2
  import { z as z4 } from 'zod/v4';
3
- import { AIMessage, HumanMessage } from '@langchain/core/messages';
3
+ import { AIMessage, AIMessageChunk, HumanMessage } from '@langchain/core/messages';
4
+ import { BaseChatModel } from '@langchain/core/language_models/chat_models';
5
+ import { ChatGenerationChunk } from '@langchain/core/outputs';
4
6
  import { Inject, Injectable } from '@nestjs/common';
5
7
  import { CommandBus } from '@nestjs/cqrs';
6
8
  import { AgentMiddlewareStrategy, CreateModelClientCommand, WrapWorkflowNodeExecutionCommand, } from '@xpert-ai/plugin-sdk';
@@ -13,6 +15,7 @@ const CONFIG_PARSE_ERROR = '敏感词过滤配置格式不正确,请检查填
13
15
  const BUSINESS_RULES_VALIDATION_ERROR = '请至少配置 1 条有效业务规则(pattern/type/action/scope/severity)。';
14
16
  const LLM_MODE_VALIDATION_ERROR = '请完善 LLM 过滤配置:需填写过滤模型、生效范围、审核规则说明。';
15
17
  const INTERNAL_LLM_INVOKE_TAG = 'sensitive-filter/internal-eval';
18
+ const INTERNAL_SOURCE_STREAM_TAG = 'sensitive-filter/internal-source-stream';
16
19
  const INTERNAL_LLM_INVOKE_OPTIONS = {
17
20
  tags: [INTERNAL_LLM_INVOKE_TAG],
18
21
  metadata: {
@@ -141,6 +144,147 @@ function replaceModelResponseText(response, text) {
141
144
  }
142
145
  return new AIMessage(text);
143
146
  }
147
+ function cloneAiMessage(source) {
148
+ return new AIMessage({
149
+ content: source.content,
150
+ additional_kwargs: source.additional_kwargs,
151
+ response_metadata: source.response_metadata,
152
+ tool_calls: source.tool_calls,
153
+ invalid_tool_calls: source.invalid_tool_calls,
154
+ usage_metadata: source.usage_metadata,
155
+ id: source.id,
156
+ name: source.name,
157
+ });
158
+ }
159
+ function cloneAiMessageWithText(source, text) {
160
+ const cloned = cloneAiMessage(source);
161
+ cloned.content = text;
162
+ return cloned;
163
+ }
164
+ function toAiMessageChunk(value) {
165
+ if (value instanceof AIMessageChunk) {
166
+ return value;
167
+ }
168
+ if (!isRecord(value) || !('content' in value)) {
169
+ return null;
170
+ }
171
+ return new AIMessageChunk({
172
+ content: value['content'],
173
+ additional_kwargs: isRecord(value['additional_kwargs']) ? value['additional_kwargs'] : {},
174
+ response_metadata: isRecord(value['response_metadata']) ? value['response_metadata'] : {},
175
+ tool_call_chunks: Array.isArray(value['tool_call_chunks']) ? value['tool_call_chunks'] : [],
176
+ tool_calls: Array.isArray(value['tool_calls']) ? value['tool_calls'] : [],
177
+ invalid_tool_calls: Array.isArray(value['invalid_tool_calls']) ? value['invalid_tool_calls'] : [],
178
+ usage_metadata: isRecord(value['usage_metadata']) ? value['usage_metadata'] : undefined,
179
+ id: typeof value['id'] === 'string' ? value['id'] : undefined,
180
+ });
181
+ }
182
+ function toAiMessage(value) {
183
+ if (value instanceof AIMessage) {
184
+ return value;
185
+ }
186
+ if (value instanceof AIMessageChunk) {
187
+ return new AIMessage({
188
+ content: value.content,
189
+ additional_kwargs: value.additional_kwargs,
190
+ response_metadata: value.response_metadata,
191
+ tool_calls: value.tool_calls,
192
+ invalid_tool_calls: value.invalid_tool_calls,
193
+ usage_metadata: value.usage_metadata,
194
+ id: value.id,
195
+ name: value.name,
196
+ });
197
+ }
198
+ if (isRecord(value) && 'content' in value) {
199
+ return new AIMessage({
200
+ content: value['content'],
201
+ additional_kwargs: isRecord(value['additional_kwargs']) ? value['additional_kwargs'] : {},
202
+ response_metadata: isRecord(value['response_metadata']) ? value['response_metadata'] : {},
203
+ tool_calls: Array.isArray(value['tool_calls']) ? value['tool_calls'] : [],
204
+ invalid_tool_calls: Array.isArray(value['invalid_tool_calls']) ? value['invalid_tool_calls'] : [],
205
+ usage_metadata: isRecord(value['usage_metadata']) ? value['usage_metadata'] : undefined,
206
+ id: typeof value['id'] === 'string' ? value['id'] : undefined,
207
+ name: typeof value['name'] === 'string' ? value['name'] : undefined,
208
+ });
209
+ }
210
+ return new AIMessage(extractPrimitiveText(value));
211
+ }
212
+ function buildInternalSourceOptions(options) {
213
+ const tags = Array.isArray(options?.tags) ? options.tags : [];
214
+ const metadata = isRecord(options?.metadata) ? options.metadata : {};
215
+ return {
216
+ ...(options ?? {}),
217
+ tags: [...tags, INTERNAL_SOURCE_STREAM_TAG],
218
+ metadata: {
219
+ ...metadata,
220
+ internal: true,
221
+ },
222
+ };
223
+ }
224
+ class BufferedOutputProxyChatModel extends BaseChatModel {
225
+ constructor(innerModel, resolveOutput) {
226
+ super({});
227
+ this.innerModel = innerModel;
228
+ this.resolveOutput = resolveOutput;
229
+ }
230
+ _llmType() {
231
+ return 'sensitive-filter-output-proxy';
232
+ }
233
+ async collectInnerMessage(messages, options) {
234
+ const internalOptions = buildInternalSourceOptions(options);
235
+ const streamFn = this.innerModel?.stream;
236
+ if (typeof streamFn === 'function') {
237
+ const stream = await streamFn.call(this.innerModel, messages, internalOptions);
238
+ if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
239
+ let mergedChunk = null;
240
+ for await (const rawChunk of stream) {
241
+ const chunk = toAiMessageChunk(rawChunk);
242
+ if (!chunk) {
243
+ continue;
244
+ }
245
+ mergedChunk = mergedChunk ? mergedChunk.concat(chunk) : chunk;
246
+ }
247
+ if (mergedChunk) {
248
+ return toAiMessage(mergedChunk);
249
+ }
250
+ }
251
+ }
252
+ return toAiMessage(await this.innerModel.invoke(messages, internalOptions));
253
+ }
254
+ async finalizeMessage(messages, options) {
255
+ const sourceMessage = await this.collectInnerMessage(messages, options);
256
+ return this.resolveOutput(sourceMessage, extractPrimitiveText(sourceMessage.content));
257
+ }
258
+ async _generate(messages, options, _runManager) {
259
+ const resolved = await this.finalizeMessage(messages, options);
260
+ return {
261
+ generations: [
262
+ {
263
+ text: extractPrimitiveText(resolved.finalMessage.content),
264
+ message: resolved.finalMessage,
265
+ },
266
+ ],
267
+ };
268
+ }
269
+ async *_streamResponseChunks(messages, options, runManager) {
270
+ const resolved = await this.finalizeMessage(messages, options);
271
+ const finalText = extractPrimitiveText(resolved.finalMessage.content);
272
+ if (!finalText) {
273
+ return;
274
+ }
275
+ const generationChunk = new ChatGenerationChunk({
276
+ message: new AIMessageChunk({
277
+ content: finalText,
278
+ id: resolved.finalMessage.id,
279
+ }),
280
+ text: finalText,
281
+ });
282
+ yield generationChunk;
283
+ await runManager?.handleLLMNewToken(finalText, undefined, undefined, undefined, undefined, {
284
+ chunk: generationChunk,
285
+ });
286
+ }
287
+ }
144
288
  function rewriteModelRequestInput(request, rewrittenText) {
145
289
  if (!Array.isArray(request?.messages) || request.messages.length === 0) {
146
290
  return request;
@@ -720,12 +864,14 @@ let SensitiveFilterMiddleware = class SensitiveFilterMiddleware {
720
864
  };
721
865
  let inputBlockedMessage = null;
722
866
  let pendingInputRewrite = null;
867
+ let bufferedOutputResolution = null;
723
868
  let finalAction = 'pass';
724
869
  let auditEntries = [];
725
870
  let runtimeConfigurable = null;
726
871
  const resetRunState = () => {
727
872
  inputBlockedMessage = null;
728
873
  pendingInputRewrite = null;
874
+ bufferedOutputResolution = null;
729
875
  finalAction = 'pass';
730
876
  auditEntries = [];
731
877
  };
@@ -847,7 +993,63 @@ let SensitiveFilterMiddleware = class SensitiveFilterMiddleware {
847
993
  }
848
994
  const modelRequest = pendingInputRewrite ? rewriteModelRequestInput(request, pendingInputRewrite) : request;
849
995
  pendingInputRewrite = null;
850
- const response = await handler(modelRequest);
996
+ bufferedOutputResolution = null;
997
+ const shouldBufferOutput = compiledRules.some((rule) => rule.scope === 'output' || rule.scope === 'both');
998
+ const effectiveRequest = shouldBufferOutput
999
+ ? {
1000
+ ...modelRequest,
1001
+ model: new BufferedOutputProxyChatModel(modelRequest.model, async (message, outputText) => {
1002
+ if (message.tool_calls?.length || message.invalid_tool_calls?.length) {
1003
+ bufferedOutputResolution = {
1004
+ finalMessage: cloneAiMessage(message),
1005
+ matched: false,
1006
+ source: 'rule',
1007
+ reason: 'tool-call-skip',
1008
+ errorPolicyTriggered: false,
1009
+ };
1010
+ return bufferedOutputResolution;
1011
+ }
1012
+ const outputMatches = findMatches(outputText, 'output', compiledRules, normalize, caseSensitive);
1013
+ const winner = pickWinningRule(outputMatches);
1014
+ if (!winner) {
1015
+ bufferedOutputResolution = {
1016
+ finalMessage: cloneAiMessage(message),
1017
+ matched: false,
1018
+ source: 'rule',
1019
+ errorPolicyTriggered: false,
1020
+ };
1021
+ return bufferedOutputResolution;
1022
+ }
1023
+ const finalText = winner.action === 'block'
1024
+ ? winner.replacementText?.trim() || DEFAULT_OUTPUT_BLOCK_MESSAGE
1025
+ : rewriteTextByRule(outputText, winner, caseSensitive);
1026
+ bufferedOutputResolution = {
1027
+ finalMessage: cloneAiMessageWithText(message, finalText),
1028
+ matched: true,
1029
+ source: 'rule',
1030
+ action: winner.action,
1031
+ reason: `rule:${winner.id}`,
1032
+ errorPolicyTriggered: false,
1033
+ };
1034
+ return bufferedOutputResolution;
1035
+ }),
1036
+ }
1037
+ : modelRequest;
1038
+ const response = await handler(effectiveRequest);
1039
+ if (bufferedOutputResolution) {
1040
+ pushAudit({
1041
+ phase: 'output',
1042
+ matched: bufferedOutputResolution.matched,
1043
+ source: bufferedOutputResolution.source,
1044
+ action: bufferedOutputResolution.action,
1045
+ reason: bufferedOutputResolution.reason,
1046
+ errorPolicyTriggered: bufferedOutputResolution.errorPolicyTriggered,
1047
+ });
1048
+ if (bufferedOutputResolution.matched && bufferedOutputResolution.action) {
1049
+ finalAction = bufferedOutputResolution.action === 'block' ? 'block' : 'rewrite';
1050
+ }
1051
+ return response;
1052
+ }
851
1053
  const outputText = extractModelResponseText(response);
852
1054
  const outputMatches = findMatches(outputText, 'output', compiledRules, normalize, caseSensitive);
853
1055
  const winner = pickWinningRule(outputMatches);
@@ -915,6 +1117,7 @@ let SensitiveFilterMiddleware = class SensitiveFilterMiddleware {
915
1117
  return structuredModelPromises.get(method);
916
1118
  };
917
1119
  let pendingInputRewrite = null;
1120
+ let bufferedOutputResolution = null;
918
1121
  let finalAction = 'pass';
919
1122
  let auditEntries = [];
920
1123
  let runtimeConfigurable = null;
@@ -923,6 +1126,7 @@ let SensitiveFilterMiddleware = class SensitiveFilterMiddleware {
923
1126
  let methodAttempts = [];
924
1127
  const resetRunState = () => {
925
1128
  pendingInputRewrite = null;
1129
+ bufferedOutputResolution = null;
926
1130
  finalAction = 'pass';
927
1131
  auditEntries = [];
928
1132
  resolvedOutputMethod = undefined;
@@ -1183,7 +1387,70 @@ let SensitiveFilterMiddleware = class SensitiveFilterMiddleware {
1183
1387
  const llmConfig = getLlmConfig();
1184
1388
  const modelRequest = pendingInputRewrite ? rewriteModelRequestInput(request, pendingInputRewrite) : request;
1185
1389
  pendingInputRewrite = null;
1186
- const response = await handler(modelRequest);
1390
+ bufferedOutputResolution = null;
1391
+ const effectiveRequest = modeIncludesScope(llmConfig.scope, 'output')
1392
+ ? {
1393
+ ...modelRequest,
1394
+ model: new BufferedOutputProxyChatModel(modelRequest.model, async (message, outputText) => {
1395
+ if (message.tool_calls?.length || message.invalid_tool_calls?.length) {
1396
+ bufferedOutputResolution = {
1397
+ finalMessage: cloneAiMessage(message),
1398
+ matched: false,
1399
+ source: 'llm',
1400
+ reason: 'tool-call-skip',
1401
+ errorPolicyTriggered: false,
1402
+ };
1403
+ return bufferedOutputResolution;
1404
+ }
1405
+ if (!outputText) {
1406
+ bufferedOutputResolution = {
1407
+ finalMessage: cloneAiMessage(message),
1408
+ matched: false,
1409
+ source: 'llm',
1410
+ reason: 'empty-output',
1411
+ errorPolicyTriggered: false,
1412
+ };
1413
+ return bufferedOutputResolution;
1414
+ }
1415
+ let decision;
1416
+ let fromErrorPolicy = false;
1417
+ try {
1418
+ decision = await invokeAndTrack('output', outputText, request?.runtime, llmConfig);
1419
+ }
1420
+ catch (error) {
1421
+ decision = resolveOnErrorDecision(llmConfig, error);
1422
+ fromErrorPolicy = true;
1423
+ }
1424
+ const finalText = decision.matched && decision.action
1425
+ ? toNonEmptyString(decision.replacementText) ?? llmConfig.rewriteFallbackText
1426
+ : outputText;
1427
+ bufferedOutputResolution = {
1428
+ finalMessage: cloneAiMessageWithText(message, finalText),
1429
+ matched: decision.matched,
1430
+ source: fromErrorPolicy ? 'error-policy' : 'llm',
1431
+ action: decision.action,
1432
+ reason: decision.reason,
1433
+ errorPolicyTriggered: fromErrorPolicy,
1434
+ };
1435
+ return bufferedOutputResolution;
1436
+ }),
1437
+ }
1438
+ : modelRequest;
1439
+ const response = await handler(effectiveRequest);
1440
+ if (bufferedOutputResolution) {
1441
+ pushAudit({
1442
+ phase: 'output',
1443
+ matched: bufferedOutputResolution.matched,
1444
+ source: bufferedOutputResolution.source,
1445
+ action: bufferedOutputResolution.action,
1446
+ reason: bufferedOutputResolution.reason,
1447
+ errorPolicyTriggered: bufferedOutputResolution.errorPolicyTriggered,
1448
+ });
1449
+ if (bufferedOutputResolution.matched && bufferedOutputResolution.action) {
1450
+ finalAction = 'rewrite';
1451
+ }
1452
+ return response;
1453
+ }
1187
1454
  if (!modeIncludesScope(llmConfig.scope, 'output')) {
1188
1455
  pushAudit({
1189
1456
  phase: 'output',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-sensitive-filter-xr",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "author": {
5
5
  "name": "XpertAI",
6
6
  "url": "https://xpertai.cn"