fix(wechat): stop relay bootstrap from hijacking LLM provider selection.
Memind CI / Test, build, and release guards (push) Failing after 2s
Memind CI / Test, build, and release guards (push) Failing after 2s
Schedule LLM now binds an explicit provider key and fails closed on network errors so dead relay endpoints cannot block ordinary chat; relay bootstrap is opt-in only. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+7
-2
@@ -178,7 +178,12 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
|||||||
# MEMIND_WECHAT_INTENT_TIMEOUT_MS=4000
|
# MEMIND_WECHAT_INTENT_TIMEOUT_MS=4000
|
||||||
# MEMIND_WECHAT_INTENT_CANARY_OPENIDS=
|
# MEMIND_WECHAT_INTENT_CANARY_OPENIDS=
|
||||||
|
|
||||||
# H5 Session stream replay(docs/h5-session-architecture-20260706.md Patch 4c)
|
# 微信待办/提醒 LLM 意图(独立于 Intent Router;须绑定 DeepSeek 等直连 key,勿依赖全局 selected)
|
||||||
|
# H5_WECHAT_SCHEDULE_LLM_ENABLED=0
|
||||||
|
# MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID=
|
||||||
|
# MEMIND_WECHAT_SCHEDULE_LLM_MODEL=deepseek-v4-pro
|
||||||
|
|
||||||
|
# H5 Session stream replay
|
||||||
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
||||||
# MEMIND_SESSION_STREAM_REPLAY=0
|
# MEMIND_SESSION_STREAM_REPLAY=0
|
||||||
|
|
||||||
@@ -232,7 +237,7 @@ H5_ADMIN_PASSWORD=change-me-admin
|
|||||||
# H5_ADMIN_WORKSPACE_ROOT=/Users/john/PycharmProjects/tkmind
|
# H5_ADMIN_WORKSPACE_ROOT=/Users/john/PycharmProjects/tkmind
|
||||||
# LLM Key 数据库加密(可选;默认派生自 TKMIND_SERVER__SECRET_KEY)
|
# LLM Key 数据库加密(可选;默认派生自 TKMIND_SERVER__SECRET_KEY)
|
||||||
# H5_SETTINGS_ENCRYPTION_KEY=change-me-encryption-key
|
# H5_SETTINGS_ENCRYPTION_KEY=change-me-encryption-key
|
||||||
# 启动时自动导入 Relay(若不存在同名配置);改用手动 Provider 时请设为 1
|
# Relay bootstrap 默认关闭;仅本地调试 relay 时设 H5_RELAY_BOOTSTRAP_DISABLED=0
|
||||||
# H5_RELAY_BOOTSTRAP_DISABLED=1
|
# H5_RELAY_BOOTSTRAP_DISABLED=1
|
||||||
# H5_RELAY_BOOTSTRAP_NAME=Local Goose Relay DeepSeek
|
# H5_RELAY_BOOTSTRAP_NAME=Local Goose Relay DeepSeek
|
||||||
# H5_RELAY_BOOTSTRAP_URL=http://127.0.0.1:18300/relay/buyer/v1/chat/completions
|
# H5_RELAY_BOOTSTRAP_URL=http://127.0.0.1:18300/relay/buyer/v1/chat/completions
|
||||||
|
|||||||
@@ -2304,9 +2304,6 @@ export function createLlmProviderService(
|
|||||||
relayProvider: RELAY_BOOTSTRAP.relayProvider,
|
relayProvider: RELAY_BOOTSTRAP.relayProvider,
|
||||||
});
|
});
|
||||||
if (!result.ok) return result;
|
if (!result.ok) return result;
|
||||||
if (result.key && !result.key.isSelected) {
|
|
||||||
await this.selectKey(result.key.id);
|
|
||||||
}
|
|
||||||
return { ok: true, created: true, key: result.key };
|
return { ok: true, created: true, key: result.key };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -190,7 +190,8 @@ export async function bootstrapPortalAgentServices({
|
|||||||
const wordFilterService =
|
const wordFilterService =
|
||||||
createWordFilterServiceFn(pool);
|
createWordFilterServiceFn(pool);
|
||||||
|
|
||||||
const relayBootstrapTask = env.H5_RELAY_BOOTSTRAP_DISABLED === '1'
|
// Relay bootstrap is opt-in only; production uses direct provider keys (e.g. DeepSeek).
|
||||||
|
const relayBootstrapTask = env.H5_RELAY_BOOTSTRAP_DISABLED !== '0'
|
||||||
? Promise.resolve({ ok: true, created: false, skipped: true })
|
? Promise.resolve({ ok: true, created: false, skipped: true })
|
||||||
: llmProviderService
|
: llmProviderService
|
||||||
.ensureBootstrapRelay()
|
.ensureBootstrapRelay()
|
||||||
|
|||||||
@@ -367,6 +367,10 @@ test('falls back from PG experience and degrades invalid image config', async ()
|
|||||||
|
|
||||||
test('keeps disabled experience null and reports background LLM failures', async () => {
|
test('keeps disabled experience null and reports background LLM failures', async () => {
|
||||||
const setup = createSetup({
|
const setup = createSetup({
|
||||||
|
env: {
|
||||||
|
EXPERIENCE_PG_URL: 'postgres://experience',
|
||||||
|
H5_RELAY_BOOTSTRAP_DISABLED: '0',
|
||||||
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
experienceEnabled: false,
|
experienceEnabled: false,
|
||||||
agentWorker: { enabled: false },
|
agentWorker: { enabled: false },
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ async function ensureConfigTable(pool) {
|
|||||||
function defaultsFromEnv(env = process.env) {
|
function defaultsFromEnv(env = process.env) {
|
||||||
return {
|
return {
|
||||||
scheduleLlmEnabled: normalizeBoolean(env.H5_WECHAT_SCHEDULE_LLM_ENABLED, false),
|
scheduleLlmEnabled: normalizeBoolean(env.H5_WECHAT_SCHEDULE_LLM_ENABLED, false),
|
||||||
|
modelProviderKeyId: String(env.MEMIND_WECHAT_SCHEDULE_LLM_MODEL_PROVIDER_KEY_ID ?? '').trim() || null,
|
||||||
|
model: String(env.MEMIND_WECHAT_SCHEDULE_LLM_MODEL ?? '').trim() || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +67,8 @@ export function createWechatScheduleLlmConfigService(pool, { env = process.env }
|
|||||||
stored.scheduleLlmEnabled,
|
stored.scheduleLlmEnabled,
|
||||||
defaults.scheduleLlmEnabled,
|
defaults.scheduleLlmEnabled,
|
||||||
),
|
),
|
||||||
|
modelProviderKeyId: String(stored.modelProviderKeyId ?? defaults.modelProviderKeyId ?? '').trim() || null,
|
||||||
|
model: String(stored.model ?? defaults.model ?? '').trim() || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +94,14 @@ export function createWechatScheduleLlmConfigService(pool, { env = process.env }
|
|||||||
payload.scheduleLlmEnabled === undefined
|
payload.scheduleLlmEnabled === undefined
|
||||||
? current.scheduleLlmEnabled
|
? current.scheduleLlmEnabled
|
||||||
: normalizeBoolean(payload.scheduleLlmEnabled, current.scheduleLlmEnabled),
|
: normalizeBoolean(payload.scheduleLlmEnabled, current.scheduleLlmEnabled),
|
||||||
|
modelProviderKeyId:
|
||||||
|
payload.modelProviderKeyId === undefined
|
||||||
|
? (current.modelProviderKeyId ?? '')
|
||||||
|
: String(payload.modelProviderKeyId ?? '').trim(),
|
||||||
|
model:
|
||||||
|
payload.model === undefined
|
||||||
|
? (current.model ?? '')
|
||||||
|
: String(payload.model ?? '').trim(),
|
||||||
};
|
};
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await pool.query(
|
await pool.query(
|
||||||
|
|||||||
@@ -60,40 +60,50 @@ function normalizeLlmScheduleIntent(payload) {
|
|||||||
return { action: 'none' };
|
return { action: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseScheduleIntentWithLlm({ text, llmProviderService, logger = console }) {
|
async function parseScheduleIntentWithLlm({ text, llmProviderService, modelProviderKeyId = null, model = null, logger = console }) {
|
||||||
if (!llmProviderService || typeof llmProviderService.createChatCompletion !== 'function') {
|
if (!llmProviderService || typeof llmProviderService.createChatCompletion !== 'function') {
|
||||||
return { action: 'none' };
|
return { action: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await llmProviderService.createChatCompletion({
|
try {
|
||||||
temperature: 0,
|
const result = await llmProviderService.createChatCompletion({
|
||||||
messages: [
|
...(modelProviderKeyId ? { providerKeyId: modelProviderKeyId } : {}),
|
||||||
{
|
...(model ? { model } : {}),
|
||||||
role: 'system',
|
temperature: 0,
|
||||||
content: [
|
messages: [
|
||||||
'你是公众号提醒助手,只做意图识别。',
|
{
|
||||||
'请严格输出 JSON,不要输出解释。',
|
role: 'system',
|
||||||
'允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。',
|
content: [
|
||||||
'create_todo 需要 title。',
|
'你是公众号提醒助手,只做意图识别。',
|
||||||
'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。',
|
'请严格输出 JSON,不要输出解释。',
|
||||||
'create_balance_alert 需要 thresholdYuan 数字。',
|
'允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。',
|
||||||
'如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。',
|
'create_todo 需要 title。',
|
||||||
'不确定时返回 none。',
|
'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。',
|
||||||
].join('\n'),
|
'create_balance_alert 需要 thresholdYuan 数字。',
|
||||||
},
|
'如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。',
|
||||||
{
|
'不确定时返回 none。',
|
||||||
role: 'user',
|
].join('\n'),
|
||||||
content: text,
|
},
|
||||||
},
|
{
|
||||||
],
|
role: 'user',
|
||||||
});
|
content: text,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown');
|
logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown');
|
||||||
|
return { action: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeLlmScheduleIntent(parseJsonReply(result.reply));
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn?.(
|
||||||
|
'[wechat-schedule-llm] parse skipped:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
return { action: 'none' };
|
return { action: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeLlmScheduleIntent(parseJsonReply(result.reply));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveScheduleIntent({
|
async function resolveScheduleIntent({
|
||||||
@@ -118,7 +128,26 @@ async function resolveScheduleIntent({
|
|||||||
}
|
}
|
||||||
if (!enabled) return ruleIntent;
|
if (!enabled) return ruleIntent;
|
||||||
|
|
||||||
return parseScheduleIntentWithLlm({ text, llmProviderService, logger });
|
let modelProviderKeyId = null;
|
||||||
|
let model = null;
|
||||||
|
try {
|
||||||
|
const config = await wechatScheduleLlmConfigService.getConfig();
|
||||||
|
modelProviderKeyId = config.modelProviderKeyId ?? null;
|
||||||
|
model = config.model ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn?.(
|
||||||
|
'[wechat-schedule-llm] provider config load failed:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseScheduleIntentWithLlm({
|
||||||
|
text,
|
||||||
|
llmProviderService,
|
||||||
|
modelProviderKeyId,
|
||||||
|
model,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleWechatScheduleIntent({
|
export async function handleWechatScheduleIntent({
|
||||||
|
|||||||
@@ -19,13 +19,28 @@ function createScheduleService() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createScheduleLlmConfigService(enabled = true) {
|
||||||
|
return {
|
||||||
|
async isScheduleLlmEnabled() {
|
||||||
|
return enabled;
|
||||||
|
},
|
||||||
|
async getConfig() {
|
||||||
|
return {
|
||||||
|
scheduleLlmEnabled: enabled,
|
||||||
|
modelProviderKeyId: 'deepseek-key',
|
||||||
|
model: 'deepseek-v4-pro',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('schedule handler keeps rule-based parsing as first priority', async () => {
|
test('schedule handler keeps rule-based parsing as first priority', async () => {
|
||||||
let llmCalled = false;
|
let llmCalled = false;
|
||||||
const reply = await handleWechatScheduleIntent({
|
const reply = await handleWechatScheduleIntent({
|
||||||
intent: { agentText: '帮我记一下 跟进合同', msgId: 'msg-1' },
|
intent: { agentText: '帮我记一下 跟进合同', msgId: 'msg-1' },
|
||||||
user: { userId: 'user-1' },
|
user: { userId: 'user-1' },
|
||||||
scheduleService: createScheduleService(),
|
scheduleService: createScheduleService(),
|
||||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
|
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||||
llmProviderService: {
|
llmProviderService: {
|
||||||
async createChatCompletion() {
|
async createChatCompletion() {
|
||||||
llmCalled = true;
|
llmCalled = true;
|
||||||
@@ -42,7 +57,7 @@ test('schedule handler can use llm fallback for ambiguous todo text', async () =
|
|||||||
intent: { agentText: '下周把合同发给客户,别忘了', msgId: 'msg-2' },
|
intent: { agentText: '下周把合同发给客户,别忘了', msgId: 'msg-2' },
|
||||||
user: { userId: 'user-1' },
|
user: { userId: 'user-1' },
|
||||||
scheduleService: createScheduleService(),
|
scheduleService: createScheduleService(),
|
||||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
|
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||||
llmProviderService: {
|
llmProviderService: {
|
||||||
async createChatCompletion() {
|
async createChatCompletion() {
|
||||||
return {
|
return {
|
||||||
@@ -71,7 +86,7 @@ test('schedule handler does not call llm when switch is off', async () => {
|
|||||||
intent: { agentText: '别忘了报销', msgId: 'msg-4' },
|
intent: { agentText: '别忘了报销', msgId: 'msg-4' },
|
||||||
user: { userId: 'user-1' },
|
user: { userId: 'user-1' },
|
||||||
scheduleService: createScheduleService(),
|
scheduleService: createScheduleService(),
|
||||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return false; } },
|
wechatScheduleLlmConfigService: createScheduleLlmConfigService(false),
|
||||||
llmProviderService: {
|
llmProviderService: {
|
||||||
async createChatCompletion() {
|
async createChatCompletion() {
|
||||||
llmCalled = true;
|
llmCalled = true;
|
||||||
@@ -89,7 +104,7 @@ test('schedule handler falls back quietly when llm request fails', async () => {
|
|||||||
intent: { agentText: '别忘了报销', msgId: 'msg-5' },
|
intent: { agentText: '别忘了报销', msgId: 'msg-5' },
|
||||||
user: { userId: 'user-1' },
|
user: { userId: 'user-1' },
|
||||||
scheduleService: createScheduleService(),
|
scheduleService: createScheduleService(),
|
||||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
|
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||||
llmProviderService: {
|
llmProviderService: {
|
||||||
async createChatCompletion() {
|
async createChatCompletion() {
|
||||||
return { ok: false, message: 'provider unavailable' };
|
return { ok: false, message: 'provider unavailable' };
|
||||||
@@ -102,12 +117,31 @@ test('schedule handler falls back quietly when llm request fails', async () => {
|
|||||||
assert.match(warnings[0], /provider unavailable/);
|
assert.match(warnings[0], /provider unavailable/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('schedule handler falls back quietly when llm request throws', async () => {
|
||||||
|
const warnings = [];
|
||||||
|
const reply = await handleWechatScheduleIntent({
|
||||||
|
intent: { agentText: '什么是机器学习', msgId: 'msg-6' },
|
||||||
|
user: { userId: 'user-1' },
|
||||||
|
scheduleService: createScheduleService(),
|
||||||
|
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||||
|
llmProviderService: {
|
||||||
|
async createChatCompletion() {
|
||||||
|
throw new TypeError('fetch failed');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: { warn: (...args) => warnings.push(args.join(' ')) },
|
||||||
|
});
|
||||||
|
assert.equal(reply, null);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(warnings[0], /fetch failed/);
|
||||||
|
});
|
||||||
|
|
||||||
test('schedule handler falls through for llm schedule_agent results', async () => {
|
test('schedule handler falls through for llm schedule_agent results', async () => {
|
||||||
const reply = await handleWechatScheduleIntent({
|
const reply = await handleWechatScheduleIntent({
|
||||||
intent: { agentText: '明天下午三点提醒我开会', msgId: 'msg-3' },
|
intent: { agentText: '明天下午三点提醒我开会', msgId: 'msg-3' },
|
||||||
user: { userId: 'user-1' },
|
user: { userId: 'user-1' },
|
||||||
scheduleService: createScheduleService(),
|
scheduleService: createScheduleService(),
|
||||||
wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } },
|
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),
|
||||||
llmProviderService: {
|
llmProviderService: {
|
||||||
async createChatCompletion() {
|
async createChatCompletion() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user