openclaw-triage-gate 1.0.1 → 1.1.0
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/ROADMAP.md +33 -0
- package/openclaw.plugin.json +45 -0
- package/package.json +4 -1
- package/src/config.ts +51 -0
- package/src/index.ts +111 -2
- package/src/triage.ts +140 -4
package/ROADMAP.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
Future enhancements planned for openclaw-triage-gate. None of these are committed to a timeline — they'll be built as needed.
|
|
4
|
+
|
|
5
|
+
## Implemented
|
|
6
|
+
|
|
7
|
+
### Keyword bypass list
|
|
8
|
+
Always respond to messages containing certain keywords regardless of triage (e.g. "help", "urgent", the bot's name). Simple case-insensitive substring matching — no model call needed. Configure via `bypassKeywords` in the plugin config.
|
|
9
|
+
|
|
10
|
+
### Confidence scores with configurable threshold
|
|
11
|
+
Instead of binary RESPOND/SKIP, the triage model returns a confidence score (1-10). Users configure a threshold — messages scoring at or above it proceed to the main model. Enable with `useConfidenceScores: true` and tune via `confidenceThreshold` (default: 5).
|
|
12
|
+
|
|
13
|
+
### Recent message history in triage context
|
|
14
|
+
Include the last N messages from the group conversation in the triage prompt. This gives the triage model better context for deciding whether the bot should respond (e.g. understanding ongoing threads, follow-up questions). Configure via `historyCount` (default: 0, max: 20). Trade-off: increases triage token cost by ~500-1000 tokens per call.
|
|
15
|
+
|
|
16
|
+
## Planned
|
|
17
|
+
|
|
18
|
+
### Custom per-group triage prompts
|
|
19
|
+
Different groups may need different triage criteria. A caregiving group might want the bot to respond more aggressively, while a social group should be more conservative. Allow `triagePrompt` to be overridden per group ID.
|
|
20
|
+
|
|
21
|
+
### Analytics and metrics
|
|
22
|
+
Track triage decisions over time: hit/miss ratio, tokens saved, false negatives, response times. Expose via a CLI command or dashboard. Helps users tune their triage prompt and verify cost savings.
|
|
23
|
+
|
|
24
|
+
### Rate-based bypass
|
|
25
|
+
In quiet groups (low message rate), skip triage and always respond. The cost savings from triage matter most in active groups. Configurable threshold (e.g. "if fewer than 5 messages in the last hour, skip triage").
|
|
26
|
+
|
|
27
|
+
### Feedback loop
|
|
28
|
+
Let users mark false negatives ("the bot should have responded to this") via a reaction or command. Store these examples and optionally include them in the triage prompt as few-shot examples.
|
|
29
|
+
|
|
30
|
+
## Considered but not planned
|
|
31
|
+
|
|
32
|
+
### Non-Anthropic/OpenAI provider formats
|
|
33
|
+
Currently supports Anthropic Messages API and OpenAI-compatible Chat Completions API. Other provider formats (e.g. Google Gemini, Cohere) could be added to `providers.ts` if there's demand.
|
package/openclaw.plugin.json
CHANGED
|
@@ -31,6 +31,31 @@
|
|
|
31
31
|
"logDecisions": {
|
|
32
32
|
"type": "boolean",
|
|
33
33
|
"description": "Whether to log each triage decision."
|
|
34
|
+
},
|
|
35
|
+
"bypassKeywords": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"items": { "type": "string" },
|
|
38
|
+
"description": "Keywords that bypass triage entirely. Messages containing any keyword (case-insensitive) always get a response."
|
|
39
|
+
},
|
|
40
|
+
"useConfidenceScores": {
|
|
41
|
+
"type": "boolean",
|
|
42
|
+
"description": "When true, the triage model returns a 1-10 confidence score instead of binary RESPOND/SKIP."
|
|
43
|
+
},
|
|
44
|
+
"confidenceThreshold": {
|
|
45
|
+
"type": "number",
|
|
46
|
+
"minimum": 1,
|
|
47
|
+
"maximum": 10,
|
|
48
|
+
"description": "Confidence threshold (1-10). Messages scoring at or above this value proceed to the main model. Only used when useConfidenceScores is true."
|
|
49
|
+
},
|
|
50
|
+
"historyCount": {
|
|
51
|
+
"type": "number",
|
|
52
|
+
"minimum": 0,
|
|
53
|
+
"maximum": 20,
|
|
54
|
+
"description": "Number of recent group messages to include in the triage prompt for context. Default: 0 (disabled), max: 20."
|
|
55
|
+
},
|
|
56
|
+
"botName": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "The bot's name. Messages containing this name bypass triage and always get a response."
|
|
34
59
|
}
|
|
35
60
|
}
|
|
36
61
|
},
|
|
@@ -58,6 +83,26 @@
|
|
|
58
83
|
"logDecisions": {
|
|
59
84
|
"label": "Log Decisions",
|
|
60
85
|
"help": "Log each triage decision to the plugin logger (default: true)"
|
|
86
|
+
},
|
|
87
|
+
"bypassKeywords": {
|
|
88
|
+
"label": "Bypass Keywords",
|
|
89
|
+
"help": "Keywords that bypass triage entirely — messages containing any keyword always get a response (case-insensitive)"
|
|
90
|
+
},
|
|
91
|
+
"useConfidenceScores": {
|
|
92
|
+
"label": "Use Confidence Scores",
|
|
93
|
+
"help": "Use 1-10 confidence scoring instead of binary RESPOND/SKIP (default: false)"
|
|
94
|
+
},
|
|
95
|
+
"confidenceThreshold": {
|
|
96
|
+
"label": "Confidence Threshold",
|
|
97
|
+
"help": "Minimum confidence score (1-10) required for the bot to respond (default: 5)"
|
|
98
|
+
},
|
|
99
|
+
"historyCount": {
|
|
100
|
+
"label": "History Count",
|
|
101
|
+
"help": "Number of recent messages to include in the triage prompt for context (default: 0, max: 20)"
|
|
102
|
+
},
|
|
103
|
+
"botName": {
|
|
104
|
+
"label": "Bot Name",
|
|
105
|
+
"help": "The bot's name — messages containing this name always get a response (e.g. 'Nox')"
|
|
61
106
|
}
|
|
62
107
|
}
|
|
63
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-triage-gate",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A lightweight triage gate for OpenClaw group chats. Uses a cheap model to decide if the bot should respond before the expensive main model runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -31,5 +31,8 @@
|
|
|
31
31
|
"install": {
|
|
32
32
|
"npmSpec": "openclaw-triage-gate"
|
|
33
33
|
}
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"vitest": "^4.1.2"
|
|
34
37
|
}
|
|
35
38
|
}
|
package/src/config.ts
CHANGED
|
@@ -51,6 +51,45 @@ export type TriageGateConfig = {
|
|
|
51
51
|
* Default: true
|
|
52
52
|
*/
|
|
53
53
|
logDecisions?: boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Keywords that bypass triage entirely. When a message contains any of
|
|
57
|
+
* these keywords (case-insensitive substring match), the bot always
|
|
58
|
+
* responds without calling the triage model.
|
|
59
|
+
*/
|
|
60
|
+
bypassKeywords?: string[];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* When true, the triage model returns a 1-10 confidence score instead
|
|
64
|
+
* of binary RESPOND/SKIP. Messages scoring at or above the threshold
|
|
65
|
+
* proceed to the main model.
|
|
66
|
+
* Default: false
|
|
67
|
+
*/
|
|
68
|
+
useConfidenceScores?: boolean;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Confidence threshold (1-10). Messages with a score at or above this
|
|
72
|
+
* value proceed to the main model. Only used when useConfidenceScores
|
|
73
|
+
* is true.
|
|
74
|
+
* Default: 5
|
|
75
|
+
*/
|
|
76
|
+
confidenceThreshold?: number;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Number of recent group messages to include in the triage prompt for
|
|
80
|
+
* additional context. Helps the model make better decisions by seeing
|
|
81
|
+
* the conversation flow. The plugin maintains its own in-memory buffer
|
|
82
|
+
* per group (resets on plugin restart).
|
|
83
|
+
* Default: 0 (disabled), Max: 20
|
|
84
|
+
*/
|
|
85
|
+
historyCount?: number;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The bot's name. When set, messages containing this name (case-insensitive)
|
|
89
|
+
* bypass triage and always get a response — similar to an @mention.
|
|
90
|
+
* Example: "Nox"
|
|
91
|
+
*/
|
|
92
|
+
botName?: string;
|
|
54
93
|
};
|
|
55
94
|
|
|
56
95
|
/** The default triage model when none is configured. */
|
|
@@ -80,3 +119,15 @@ SKIP when:
|
|
|
80
119
|
- A response would just be acknowledgment ("nice", "yeah", "lol")
|
|
81
120
|
- The conversation is flowing fine without the bot
|
|
82
121
|
- The message is a reaction, emoji, or sticker`;
|
|
122
|
+
|
|
123
|
+
/** Default confidence threshold when useConfidenceScores is enabled. */
|
|
124
|
+
export const DEFAULT_CONFIDENCE_THRESHOLD = 5;
|
|
125
|
+
|
|
126
|
+
/** Default number of recent messages to include in triage context. */
|
|
127
|
+
export const DEFAULT_HISTORY_COUNT = 0;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The built-in confidence-scoring prompt. Instructs the model to reply
|
|
131
|
+
* with a single number 1-10 indicating response likelihood.
|
|
132
|
+
*/
|
|
133
|
+
export const DEFAULT_CONFIDENCE_PROMPT = `Reply with a single number 1-10 indicating how likely the bot should respond. 1 = definitely skip, 10 = definitely respond. Reply with ONLY the number.`;
|
package/src/index.ts
CHANGED
|
@@ -10,9 +10,62 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
13
|
-
import { evaluateMessage } from "./triage.js";
|
|
13
|
+
import { evaluateMessage, containsBypassKeyword } from "./triage.js";
|
|
14
14
|
import { type TriageGateConfig } from "./config.js";
|
|
15
15
|
|
|
16
|
+
// Guard against multiple registrations. OpenClaw calls register() for each
|
|
17
|
+
// agent context, but we only need one before_dispatch hook globally.
|
|
18
|
+
let registered = false;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* In-memory ring buffer that accumulates recent messages per group.
|
|
22
|
+
* Keyed by sessionKey (group ID). Resets on plugin restart — this is
|
|
23
|
+
* acceptable for triage context since it's best-effort.
|
|
24
|
+
*/
|
|
25
|
+
const groupHistoryBuffers = new Map<
|
|
26
|
+
string,
|
|
27
|
+
Array<{ role: string; content: string; ts: number }>
|
|
28
|
+
>();
|
|
29
|
+
|
|
30
|
+
/** Evict groups with no activity in the last hour to prevent unbounded growth. */
|
|
31
|
+
const EVICT_AFTER_MS = 60 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
function pushToBuffer(
|
|
34
|
+
groupId: string,
|
|
35
|
+
senderId: string,
|
|
36
|
+
content: string,
|
|
37
|
+
maxSize: number,
|
|
38
|
+
): void {
|
|
39
|
+
let buffer = groupHistoryBuffers.get(groupId);
|
|
40
|
+
if (!buffer) {
|
|
41
|
+
buffer = [];
|
|
42
|
+
groupHistoryBuffers.set(groupId, buffer);
|
|
43
|
+
}
|
|
44
|
+
buffer.push({ role: senderId, content, ts: Date.now() });
|
|
45
|
+
// Keep only the last maxSize entries
|
|
46
|
+
if (buffer.length > maxSize) {
|
|
47
|
+
buffer.splice(0, buffer.length - maxSize);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getBufferedHistory(
|
|
52
|
+
groupId: string,
|
|
53
|
+
count: number,
|
|
54
|
+
): Array<{ role: string; content: string }> | undefined {
|
|
55
|
+
const buffer = groupHistoryBuffers.get(groupId);
|
|
56
|
+
if (!buffer?.length) return undefined;
|
|
57
|
+
return buffer.slice(-count).map(({ role, content }) => ({ role, content }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function evictStaleBuffers(): void {
|
|
61
|
+
const cutoff = Date.now() - EVICT_AFTER_MS;
|
|
62
|
+
for (const [key, buffer] of groupHistoryBuffers) {
|
|
63
|
+
if (!buffer.length || buffer[buffer.length - 1].ts < cutoff) {
|
|
64
|
+
groupHistoryBuffers.delete(key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
16
69
|
export default definePluginEntry({
|
|
17
70
|
id: "openclaw-triage-gate",
|
|
18
71
|
name: "Triage Gate",
|
|
@@ -20,8 +73,17 @@ export default definePluginEntry({
|
|
|
20
73
|
"Uses a cheap model to decide if the bot should respond in group chats, saving 75-90% of group chat token costs.",
|
|
21
74
|
|
|
22
75
|
register(api: OpenClawPluginApi) {
|
|
76
|
+
if (registered) return;
|
|
77
|
+
registered = true;
|
|
78
|
+
|
|
23
79
|
const config = (api.pluginConfig ?? {}) as TriageGateConfig;
|
|
24
80
|
const logDecisions = config.logDecisions !== false; // default: true
|
|
81
|
+
const historyCount = Math.min(Math.max(config.historyCount ?? 0, 0), 20);
|
|
82
|
+
|
|
83
|
+
// Resolve bot name: explicit config > first agent's identity.name from OpenClaw config
|
|
84
|
+
const ocConfig = api.config as { agents?: { list?: Array<{ identity?: { name?: string } }> } };
|
|
85
|
+
const agentIdentityName = ocConfig.agents?.list?.[0]?.identity?.name;
|
|
86
|
+
const botNameLower = (config.botName ?? agentIdentityName ?? "").toLowerCase();
|
|
25
87
|
|
|
26
88
|
// Pre-compute the set of groups to include/exclude for fast lookups
|
|
27
89
|
const includeGroups = config.groups?.length
|
|
@@ -31,6 +93,11 @@ export default definePluginEntry({
|
|
|
31
93
|
? new Set(config.excludeGroups)
|
|
32
94
|
: null;
|
|
33
95
|
|
|
96
|
+
// Periodically evict stale group buffers (every 10 minutes)
|
|
97
|
+
if (historyCount > 0) {
|
|
98
|
+
setInterval(evictStaleBuffers, 10 * 60 * 1000);
|
|
99
|
+
}
|
|
100
|
+
|
|
34
101
|
/**
|
|
35
102
|
* Resolve an API key for a provider/model using OpenClaw's auth system.
|
|
36
103
|
* This keeps the plugin model-agnostic — it works with any provider
|
|
@@ -69,18 +136,60 @@ export default definePluginEntry({
|
|
|
69
136
|
return { handled: true }; // Skip silently
|
|
70
137
|
}
|
|
71
138
|
|
|
139
|
+
// Always respond when the bot's name is mentioned in the message
|
|
140
|
+
if (botNameLower && event.content.toLowerCase().includes(botNameLower)) {
|
|
141
|
+
if (logDecisions) {
|
|
142
|
+
api.logger.info?.(`triage-gate: RESPOND (bot name mentioned) — "${event.content.slice(0, 80)}"`);
|
|
143
|
+
}
|
|
144
|
+
// Still record in history buffer before passing through
|
|
145
|
+
if (historyCount > 0) {
|
|
146
|
+
pushToBuffer(groupId, event.senderId ?? "unknown", event.content, historyCount);
|
|
147
|
+
}
|
|
148
|
+
return; // let message through without triage
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check for bypass keywords — if matched, skip triage entirely
|
|
152
|
+
if (config.bypassKeywords?.length) {
|
|
153
|
+
const matched = containsBypassKeyword(event.content, config.bypassKeywords);
|
|
154
|
+
if (matched) {
|
|
155
|
+
if (logDecisions) {
|
|
156
|
+
api.logger.info?.(`triage-gate: BYPASS (keyword: ${matched})`);
|
|
157
|
+
}
|
|
158
|
+
if (historyCount > 0) {
|
|
159
|
+
pushToBuffer(groupId, event.senderId ?? "unknown", event.content, historyCount);
|
|
160
|
+
}
|
|
161
|
+
return; // undefined = let message through without triage
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Get recent messages from the in-memory buffer
|
|
166
|
+
const recentMessages = historyCount > 0
|
|
167
|
+
? getBufferedHistory(groupId, historyCount)
|
|
168
|
+
: undefined;
|
|
169
|
+
|
|
170
|
+
// Record this message in the buffer (after reading history so this
|
|
171
|
+
// message isn't included as "recent" context for itself)
|
|
172
|
+
if (historyCount > 0) {
|
|
173
|
+
pushToBuffer(groupId, event.senderId ?? "unknown", event.content, historyCount);
|
|
174
|
+
}
|
|
175
|
+
|
|
72
176
|
// Run the triage model
|
|
73
177
|
const result = await evaluateMessage({
|
|
74
178
|
content: event.content,
|
|
179
|
+
senderName: event.senderId,
|
|
75
180
|
config,
|
|
76
181
|
resolveApiKey,
|
|
77
182
|
logger: logDecisions ? api.logger : undefined,
|
|
183
|
+
recentMessages,
|
|
78
184
|
});
|
|
79
185
|
|
|
80
186
|
if (logDecisions) {
|
|
81
187
|
const decision = result.shouldRespond ? "RESPOND" : "SKIP";
|
|
188
|
+
const scoreInfo = result.confidenceScore != null
|
|
189
|
+
? `score: ${result.confidenceScore}/10, `
|
|
190
|
+
: "";
|
|
82
191
|
api.logger.info?.(
|
|
83
|
-
`triage-gate: ${decision} (${result.durationMs}ms) — "${event.content.slice(0, 80)}"`,
|
|
192
|
+
`triage-gate: ${decision} (${scoreInfo}${result.durationMs}ms) — "${event.content.slice(0, 80)}"`,
|
|
84
193
|
);
|
|
85
194
|
}
|
|
86
195
|
|
package/src/triage.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
DEFAULT_TRIAGE_MODEL,
|
|
13
13
|
DEFAULT_TRIAGE_PROMPT,
|
|
14
14
|
DEFAULT_MAX_TRIAGE_TOKENS,
|
|
15
|
+
DEFAULT_CONFIDENCE_THRESHOLD,
|
|
16
|
+
DEFAULT_CONFIDENCE_PROMPT,
|
|
15
17
|
type TriageGateConfig,
|
|
16
18
|
} from "./config.js";
|
|
17
19
|
import { parseModelString, getProviderAdapter } from "./providers.js";
|
|
@@ -24,6 +26,9 @@ type TriageParams = {
|
|
|
24
26
|
/** The message content to evaluate. */
|
|
25
27
|
content: string;
|
|
26
28
|
|
|
29
|
+
/** Sender identifier (when available). */
|
|
30
|
+
senderName?: string;
|
|
31
|
+
|
|
27
32
|
/** Plugin config. */
|
|
28
33
|
config: TriageGateConfig;
|
|
29
34
|
|
|
@@ -38,6 +43,9 @@ type TriageParams = {
|
|
|
38
43
|
info?: (msg: string) => void;
|
|
39
44
|
warn?: (msg: string) => void;
|
|
40
45
|
};
|
|
46
|
+
|
|
47
|
+
/** Recent messages from the group conversation for additional context. */
|
|
48
|
+
recentMessages?: Array<{ role: string; content: string }>;
|
|
41
49
|
};
|
|
42
50
|
|
|
43
51
|
export type TriageResult = {
|
|
@@ -49,6 +57,9 @@ export type TriageResult = {
|
|
|
49
57
|
|
|
50
58
|
/** How long the triage call took in milliseconds. */
|
|
51
59
|
durationMs: number;
|
|
60
|
+
|
|
61
|
+
/** Confidence score 1-10 when useConfidenceScores is enabled. */
|
|
62
|
+
confidenceScore?: number;
|
|
52
63
|
};
|
|
53
64
|
|
|
54
65
|
// ---------------------------------------------------------------------------
|
|
@@ -66,12 +77,14 @@ export type TriageResult = {
|
|
|
66
77
|
* silently drop messages.
|
|
67
78
|
*/
|
|
68
79
|
export async function evaluateMessage(params: TriageParams): Promise<TriageResult> {
|
|
69
|
-
const { content, config, resolveApiKey, logger } = params;
|
|
80
|
+
const { content, senderName, config, resolveApiKey, logger, recentMessages } = params;
|
|
70
81
|
const startTime = Date.now();
|
|
71
82
|
|
|
72
83
|
const modelString = config.triageModel ?? DEFAULT_TRIAGE_MODEL;
|
|
73
84
|
const { provider, model } = parseModelString(modelString);
|
|
74
|
-
const
|
|
85
|
+
const useConfidence = config.useConfidenceScores === true;
|
|
86
|
+
const prompt = config.triagePrompt
|
|
87
|
+
?? (useConfidence ? DEFAULT_CONFIDENCE_PROMPT : DEFAULT_TRIAGE_PROMPT);
|
|
75
88
|
const maxTokens = config.maxTriageTokens ?? DEFAULT_MAX_TRIAGE_TOKENS;
|
|
76
89
|
|
|
77
90
|
try {
|
|
@@ -85,6 +98,9 @@ export async function evaluateMessage(params: TriageParams): Promise<TriageResul
|
|
|
85
98
|
// Get the right adapter for this provider's API format
|
|
86
99
|
const adapter = getProviderAdapter(provider);
|
|
87
100
|
|
|
101
|
+
// Build the user message with available context
|
|
102
|
+
const userMessage = buildTriageUserMessage({ content, senderName, recentMessages });
|
|
103
|
+
|
|
88
104
|
// Make the API call
|
|
89
105
|
const response = await fetch(adapter.endpoint, {
|
|
90
106
|
method: "POST",
|
|
@@ -92,7 +108,7 @@ export async function evaluateMessage(params: TriageParams): Promise<TriageResul
|
|
|
92
108
|
body: adapter.buildRequestBody({
|
|
93
109
|
model,
|
|
94
110
|
systemPrompt: prompt,
|
|
95
|
-
userMessage
|
|
111
|
+
userMessage,
|
|
96
112
|
maxTokens,
|
|
97
113
|
}),
|
|
98
114
|
signal: AbortSignal.timeout(5000), // 5s timeout — triage should be fast
|
|
@@ -108,8 +124,15 @@ export async function evaluateMessage(params: TriageParams): Promise<TriageResul
|
|
|
108
124
|
|
|
109
125
|
const body = await response.json();
|
|
110
126
|
const rawResponse = adapter.extractResponse(body);
|
|
111
|
-
const shouldRespond = parseTriageDecision(rawResponse);
|
|
112
127
|
|
|
128
|
+
if (useConfidence) {
|
|
129
|
+
const confidenceScore = parseConfidenceScore(rawResponse);
|
|
130
|
+
const threshold = config.confidenceThreshold ?? DEFAULT_CONFIDENCE_THRESHOLD;
|
|
131
|
+
const shouldRespond = confidenceScore >= threshold;
|
|
132
|
+
return { shouldRespond, rawResponse, durationMs: Date.now() - startTime, confidenceScore };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const shouldRespond = parseTriageDecision(rawResponse);
|
|
113
136
|
return { shouldRespond, rawResponse, durationMs: Date.now() - startTime };
|
|
114
137
|
} catch (error) {
|
|
115
138
|
// On any error, default to letting the message through.
|
|
@@ -119,6 +142,92 @@ export async function evaluateMessage(params: TriageParams): Promise<TriageResul
|
|
|
119
142
|
}
|
|
120
143
|
}
|
|
121
144
|
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// User message construction
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build the user message sent to the triage model, including available context.
|
|
151
|
+
* This gives the triage model enough information to make informed decisions
|
|
152
|
+
* about messages like "yes please" or "can you elaborate?" that only make
|
|
153
|
+
* sense in context.
|
|
154
|
+
*/
|
|
155
|
+
export function buildTriageUserMessage(params: {
|
|
156
|
+
content: string;
|
|
157
|
+
senderName?: string;
|
|
158
|
+
recentMessages?: Array<{ role: string; content: string }>;
|
|
159
|
+
}): string {
|
|
160
|
+
const parts: string[] = [];
|
|
161
|
+
|
|
162
|
+
if (params.senderName) {
|
|
163
|
+
parts.push(`From: ${params.senderName}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
parts.push(`Message: ${params.content}`);
|
|
167
|
+
|
|
168
|
+
if (params.recentMessages?.length) {
|
|
169
|
+
parts.push("");
|
|
170
|
+
parts.push(formatMessageHistory(params.recentMessages));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return parts.join("\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Bypass keyword check
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Check whether the message content contains any of the configured bypass
|
|
182
|
+
* keywords. Matching is case-insensitive and uses substring containment.
|
|
183
|
+
*
|
|
184
|
+
* @returns The first matched keyword (lowercased), or `null` if none match.
|
|
185
|
+
*/
|
|
186
|
+
export function containsBypassKeyword(
|
|
187
|
+
content: string,
|
|
188
|
+
keywords: string[],
|
|
189
|
+
): string | null {
|
|
190
|
+
if (!content || keywords.length === 0) return null;
|
|
191
|
+
|
|
192
|
+
const lowerContent = content.toLowerCase();
|
|
193
|
+
for (const keyword of keywords) {
|
|
194
|
+
const lowerKeyword = keyword.toLowerCase();
|
|
195
|
+
if (lowerContent.includes(lowerKeyword)) {
|
|
196
|
+
return lowerKeyword;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Message history formatting
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
const MAX_MESSAGE_CONTENT_LENGTH = 200;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Format an array of recent messages into a human-readable string for the
|
|
210
|
+
* triage prompt. Each message is rendered as "- [role]: [content]".
|
|
211
|
+
*
|
|
212
|
+
* Message content longer than 200 characters is truncated with "...".
|
|
213
|
+
* Returns an empty string for an empty array.
|
|
214
|
+
*/
|
|
215
|
+
export function formatMessageHistory(
|
|
216
|
+
messages: Array<{ role: string; content: string }>,
|
|
217
|
+
): string {
|
|
218
|
+
if (messages.length === 0) return "";
|
|
219
|
+
|
|
220
|
+
const lines = messages.map(({ role, content }) => {
|
|
221
|
+
const truncated =
|
|
222
|
+
content.length > MAX_MESSAGE_CONTENT_LENGTH
|
|
223
|
+
? content.slice(0, MAX_MESSAGE_CONTENT_LENGTH) + "..."
|
|
224
|
+
: content;
|
|
225
|
+
return `- ${role}: ${truncated}`;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return `Recent conversation:\n${lines.join("\n")}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
122
231
|
// ---------------------------------------------------------------------------
|
|
123
232
|
// Helpers
|
|
124
233
|
// ---------------------------------------------------------------------------
|
|
@@ -140,3 +249,30 @@ export function parseTriageDecision(response: string): boolean {
|
|
|
140
249
|
// are worse than false positives (bot responds when it didn't need to).
|
|
141
250
|
return true;
|
|
142
251
|
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parse a confidence score (1-10) from the triage model's response.
|
|
255
|
+
*
|
|
256
|
+
* Extraction strategy:
|
|
257
|
+
* 1. Look for the first integer 1-10 in the text
|
|
258
|
+
* 2. Fall back to keyword matching: "RESPOND" -> 10, "SKIP" -> 1
|
|
259
|
+
* 3. Default to 10 for ambiguous/empty responses (safe default — respond)
|
|
260
|
+
*/
|
|
261
|
+
export function parseConfidenceScore(response: string): number {
|
|
262
|
+
const trimmed = response.trim();
|
|
263
|
+
|
|
264
|
+
// Try to extract the first integer 1-10 from the response
|
|
265
|
+
const match = trimmed.match(/\b(10|[1-9])\b/);
|
|
266
|
+
if (match) {
|
|
267
|
+
return parseInt(match[1], 10);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Backward compatibility: map RESPOND/SKIP keywords to scores
|
|
271
|
+
const upper = trimmed.toUpperCase();
|
|
272
|
+
if (upper.startsWith("RESPOND")) return 10;
|
|
273
|
+
if (upper.startsWith("SKIP")) return 1;
|
|
274
|
+
|
|
275
|
+
// Default to 10 (respond) for ambiguous or empty input.
|
|
276
|
+
// Same philosophy as parseTriageDecision: better to respond than drop.
|
|
277
|
+
return 10;
|
|
278
|
+
}
|