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 +2 -6
- package/dist/lib/sensitiveFilter.d.ts.map +1 -1
- package/dist/lib/sensitiveFilter.js +270 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sensitive Filter Middleware
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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":"
|
|
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
|
-
|
|
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
|
-
|
|
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',
|