diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 4ec0899..d1c4773 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -208,6 +208,40 @@ export function isMemoryRecallQuestion(text) { return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized)); } +const PAGE_WORKFLOW_QUERY_PATTERNS = [ + /(?:生成|做成|整理|排版|导出|发布).{0,8}(?:页面|网页|html|专题|报告页|推文)/iu, + /(?:mindspace|MindSpace)/u, + /每日新闻/u, +]; + +const PAGE_WORKFLOW_MEMORY_PATTERNS = [ + /(?:做成|生成|整理|排版).{0,8}(?:页面|网页|html|专题|报告)/iu, + /每日新闻/u, + /(?:mindspace|MindSpace)/u, + /页面.{0,8}(?:偏好|习惯|模板)/u, +]; + +export function queryImpliesPageWorkflow(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return PAGE_WORKFLOW_QUERY_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +function isPageWorkflowMemory(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return PAGE_WORKFLOW_MEMORY_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +export function shouldSkipAmbiguousMemoryQuery(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return true; + if (isMemoryRecallQuestion(normalized)) return false; + if (pgvectorMemoryBackendInternals.extractKeywordTerms(normalized).length > 0) return false; + // Short acknowledgements/fillers without a topic anchor (e.g. 「可以」「先不聊了」). + return normalized.length <= 4; +} + function memoryItemText(item) { return String(item?.text ?? item?.memory_text ?? item?.content ?? '').trim(); } @@ -229,6 +263,13 @@ export function scoreAgentMemoryCandidate(item, query) { } let score = (lexical * 100) + keywordBoost; if (isEpisodicMemoryItem(item) && score > 0) score += 5; + if ( + isPageWorkflowMemory(text) + && !queryImpliesPageWorkflow(query) + && !isMemoryRecallQuestion(query) + ) { + score -= 100; + } return score; } @@ -1366,6 +1407,11 @@ export function createChatIntentRouter(options = {}) { agentMemoryMetrics.lastReason = 'intervention_skip'; return { ...base, reason: 'intervention_skip' }; } + if (shouldSkipAmbiguousMemoryQuery(text)) { + agentMemoryMetrics.skipped += 1; + agentMemoryMetrics.lastReason = 'ambiguous_query_skip'; + return { ...base, reason: 'ambiguous_query_skip' }; + } const startedAt = Date.now(); try { let personalFailed = false; diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index a15dcfe..cd4be9c 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -31,6 +31,7 @@ import { resolveChatIntentRouterPolicy, mergeAgentMemoryCandidates, scoreAgentMemoryCandidate, + shouldSkipAmbiguousMemoryQuery, } from './chat-intent-router.mjs'; /** Ambiguous user text that should miss FAQ/rules and exercise LLM router paths in tests. */ @@ -1398,6 +1399,66 @@ test('scoreAgentMemoryCandidate boosts episodic matches when topic overlap exist assert.ok(episodicScore > personalScore); }); +test('shouldSkipAmbiguousMemoryQuery skips filler without topic anchor', () => { + assert.equal(shouldSkipAmbiguousMemoryQuery('可以'), true); + assert.equal(shouldSkipAmbiguousMemoryQuery('我们继续聊聊德川家康'), false); + assert.equal(shouldSkipAmbiguousMemoryQuery('9岁小孩教育'), false); + assert.equal(shouldSkipAmbiguousMemoryQuery('你记得我们聊过什么吗'), false); +}); + +test('agent memory skips resolve for ambiguous short filler', async () => { + const calls = []; + const router = createChatIntentRouter({ + env: { + MEMORY_AGENT_RESOLVE_ENABLED: '1', + MEMORY_AGENT_INJECTION_MODE: 'active', + }, + memoryV2: { + async resolve(input) { + calls.push(input); + return { memories: [{ label: 'preference', text: '每日新闻页面偏好' }] }; + }, + }, + }); + + const result = await router.resolveAgentMemoryContext({ + userId: 'user-tang', + text: '可以', + }); + + assert.equal(result.skipped, true); + assert.equal(result.reason, 'ambiguous_query_skip'); + assert.equal(result.memories.length, 0); + assert.equal(calls.length, 0); +}); + +test('agent memory merge demotes page workflow memories when query is general chat', () => { + const merged = mergeAgentMemoryCandidates({ + query: '9岁小孩教育,孩子玩手机超时怎么办', + limit: 2, + personalMemories: [ + { id: 'personal:news', label: 'preference', text: '用户希望整理每日新闻并做成页面发布' }, + { id: 'personal:kid', label: 'experience', text: '用户关心9岁孩子教育与手机使用边界' }, + ], + }); + + assert.equal(merged[0].id, 'personal:kid'); +}); + +test('scoreAgentMemoryCandidate penalizes page workflow memories on general chat', () => { + const pageScore = scoreAgentMemoryCandidate({ + id: 'personal:news', + label: 'preference', + text: '用户希望整理每日新闻并做成页面发布', + }, '可以,我们继续聊孩子教育'); + const topicScore = scoreAgentMemoryCandidate({ + id: 'personal:kid', + label: 'experience', + text: '用户关心9岁孩子教育与手机使用边界', + }, '可以,我们继续聊孩子教育'); + assert.ok(topicScore > pageScore); +}); + test('active agent memory context is hidden from displayText but available to orchestration envelope', () => { const enriched = applyAgentOrchestrationToUserMessage( { diff --git a/scheduled-task-executor.mjs b/scheduled-task-executor.mjs index f9db16a..79242e4 100644 --- a/scheduled-task-executor.mjs +++ b/scheduled-task-executor.mjs @@ -30,6 +30,8 @@ export function buildScheduledTaskExecutionPrompt(task, { `任务要求:${task.taskSpec}`, '执行约束:', '- 这是系统自动触发的定时任务,请直接完成可交付结果,不要反问用户。', + '- 禁止向用户追问时间、频率或任务内容;taskSpec 已是完整执行说明。', + '- 禁止调用 scheduled_task_create / scheduled_task_list / scheduled_task_cancel;只执行 taskSpec。', '- 若需要生成页面,必须先 load_skill → static-page-publish,再 write_file 到 public/*.html,并给出正式可访问 URL。', '- 若只需摘要/文本,给出完整中文结果摘要。', '- 完成后在回复中明确写出交付结果(链接或摘要)。', @@ -69,6 +71,31 @@ export function formatScheduledTaskDeliveryMessage(task, deliveryText) { return `${header}\n\n${body}`.trim(); } +const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [ + /需确认/u, + /请确认/u, + /请问/u, + /未指定/u, + /需要澄清/u, + /在创建前需要确认/u, + /信息不完整/u, + /具体几点/u, + /缺(?:少|失)/u, +]; + +export function looksLikeScheduledTaskNonDelivery(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return true; + if (SCHEDULED_TASK_CLARIFICATION_PATTERNS.some((pattern) => pattern.test(normalized))) { + return true; + } + if (/https?:\/\//i.test(normalized)) return false; + if (/public\/[^\s]+\.html/i.test(normalized)) return false; + if (/页面链接/u.test(normalized)) return false; + if (/(?:已生成|已完成|交付).{0,24}(?:页面|链接|结果)/u.test(normalized)) return false; + return normalized.length < 40; +} + export async function finalizeScheduledTaskPageDelivery({ pool, userId, diff --git a/scheduled-task-executor.test.mjs b/scheduled-task-executor.test.mjs index 1042bec..85466f5 100644 --- a/scheduled-task-executor.test.mjs +++ b/scheduled-task-executor.test.mjs @@ -4,6 +4,7 @@ import { buildScheduledTaskExecutionPrompt, extractScheduledTaskDeliveryText, formatScheduledTaskDeliveryMessage, + looksLikeScheduledTaskNonDelivery, } from './scheduled-task-executor.mjs'; test('buildScheduledTaskExecutionPrompt includes task spec and automation marker', () => { @@ -19,6 +20,18 @@ test('buildScheduledTaskExecutionPrompt includes task spec and automation marker assert.match(text, /每日新闻页/); assert.match(text, /搜索今日新闻并生成 HTML 页面/); assert.match(text, /Scheduled Automation/); + assert.match(text, /禁止调用 scheduled_task_create/); +}); + +test('looksLikeScheduledTaskNonDelivery detects clarification replies', () => { + assert.equal( + looksLikeScheduledTaskNonDelivery('请问 7月31日具体几点执行这个任务?'), + true, + ); + assert.equal( + looksLikeScheduledTaskNonDelivery('页面已生成:https://example.com/news.html'), + false, + ); }); test('extractScheduledTaskDeliveryText reads last assistant message', () => { diff --git a/scheduled-task-worker.mjs b/scheduled-task-worker.mjs index c1d1e88..b5bc61d 100644 --- a/scheduled-task-worker.mjs +++ b/scheduled-task-worker.mjs @@ -1,6 +1,7 @@ import { executeScheduledTask, formatScheduledTaskDeliveryMessage, + looksLikeScheduledTaskNonDelivery, } from './scheduled-task-executor.mjs'; export function startScheduledTaskWorker({ @@ -77,6 +78,11 @@ export function startScheduledTaskWorker({ timeoutMs: executionTimeoutMs, logger, }); + if (looksLikeScheduledTaskNonDelivery(result.deliveryText)) { + const err = new Error('定时任务未产出可交付结果'); + err.code = 'SCHEDULED_TASK_NON_DELIVERY'; + throw err; + } await deliverTaskResult(task, result.deliveryText); await scheduledTaskService.markTaskSucceeded(task, { result: { diff --git a/scheduled-task-worker.test.mjs b/scheduled-task-worker.test.mjs index 31bd9d2..b8cf693 100644 --- a/scheduled-task-worker.test.mjs +++ b/scheduled-task-worker.test.mjs @@ -123,3 +123,55 @@ test('scheduled task worker marks failure after execution error', async () => { assert.deepEqual(calls, ['failed:3:agent timeout', 'notify:scheduled_task_failed']); }); + +test('scheduled task worker marks failure when agent only asks for clarification', async () => { + const calls = []; + const task = { + id: 'task-3', + userId: 'user-3', + title: '每日诗意清晨', + recurrence: 'daily', + notifyChannel: 'web', + attempts: 1, + }; + const worker = startScheduledTaskWorker({ + intervalMs: 60_000, + userAuth: { id: 'user-auth' }, + tkmindProxy: { id: 'proxy' }, + scheduledTaskService: { + async listDueTasks() { + return [task]; + }, + async lockTask() { + return task; + }, + async markTaskRunning(input) { + return input; + }, + async markTaskSucceeded() { + calls.push('success'); + }, + async markTaskFailed(input, err) { + calls.push(`failed:${err.code}`); + return { ...input, status: 'failed', lastError: err.message }; + }, + }, + scheduleService: { + async createUserNotification(input) { + calls.push(`notify:${input.notificationType}`); + }, + }, + executeTask: async () => ({ + sessionId: 'session-3', + requestId: 'req-3', + deliveryText: '请问每天早上几点执行?', + }), + logger: { warn() {} }, + runOnStart: false, + }); + + await worker.runOnce(); + worker.stop(); + + assert.deepEqual(calls, ['failed:SCHEDULED_TASK_NON_DELIVERY', 'notify:scheduled_task_failed']); +}); diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 7c5b319..53df969 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -3525,6 +3525,12 @@ export function createWechatMpService({ wechatScheduleLlmConfigService, llmProviderService, logger, + }).catch((err) => { + logger.warn?.( + 'WeChat MP schedule intent handling failed open:', + err instanceof Error ? err.message : err, + ); + return null; }) : null; if (scheduleReply) { diff --git a/wechat/intent/patterns.mjs b/wechat/intent/patterns.mjs index a7843b1..d42e050 100644 --- a/wechat/intent/patterns.mjs +++ b/wechat/intent/patterns.mjs @@ -4,6 +4,7 @@ import { isWechatPageEditText, isWechatPageRetryText, } from './page-continuation.mjs'; +import { shouldUseScheduledTaskAutomation } from '../../scheduled-task-intent.mjs'; export const PAGE_GENERATE_PATTERN = /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/iu; @@ -29,6 +30,7 @@ export const CONNECTIVITY_TEST_PATTERN = /^(测试\s*\d*|test\s*\d*)[!!。.\s] export function isPageGenerateText(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; + if (shouldUseScheduledTaskAutomation(normalized)) return false; if (PAGE_GENERATE_NEGATION_PATTERN.test(normalized)) return false; return ( PAGE_GENERATE_PATTERN.test(normalized) diff --git a/wechat/wechat-channel.test.mjs b/wechat/wechat-channel.test.mjs index a24e596..6c47a00 100644 --- a/wechat/wechat-channel.test.mjs +++ b/wechat/wechat-channel.test.mjs @@ -33,6 +33,15 @@ test('classifyWechatIntent detects page.generate', () => { assert.equal(isPageGenerateIntent({ msgType: 'text', agentText: intent.topic }), true); }); +test('classifyWechatIntent keeps scheduled automation on chat.general', () => { + const intent = classifyWechatIntent({ + msgType: 'text', + agentText: '我想创建一个定时执行任务,今晚 21:45 分执行做新闻页面', + }); + assert.equal(intent.kind, 'chat.general'); + assert.equal(isPageGenerateIntent({ msgType: 'text', agentText: intent.text }), false); +}); + test('classifyWechatIntent detects session.reset', () => { assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换话题' }).kind, 'session.reset'); assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换新会话' }).kind, 'session.reset');