From eea14c1855821b954c842658b1a676848a61cda7 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 8 Jul 2026 21:10:47 +0800 Subject: [PATCH] Add page data delivery and publication guards --- agent-run-deliverable-check.mjs | 74 +++++ agent-run-deliverable-check.test.mjs | 77 +++++ agent-run-gateway.mjs | 96 ++++-- agent-run-gateway.test.mjs | 53 +++- capabilities.mjs | 4 + capabilities.test.mjs | 12 + chat-intent-router.mjs | 216 +++++++------- chat-intent-router.test.mjs | 206 ++++++------- chat-skills.mjs | 46 ++- chat-skills.test.mjs | 23 ++ conversation-memory.mjs | 34 ++- conversation-memory.test.mjs | 26 ++ conversation-repair.mjs | 5 + conversation-repair.test.mjs | 13 + direct-chat-service.mjs | 22 +- docs/examples/page-data-survey-test.md | 75 +++++ docs/page-data-api-usage.md | 7 +- html-document-injection.mjs | 30 ++ html-document-injection.test.mjs | 28 ++ memory-legacy-fallback.mjs | 78 +++++ memory-legacy-fallback.test.mjs | 41 +++ memory-v2-admin-config.mjs | 3 + mindspace-content-scan.mjs | 1 + mindspace-content-scan.test.mjs | 16 + mindspace-page-sync.mjs | 27 +- mindspace-page-sync.test.mjs | 7 +- mindspace-page-tag.mjs | 6 +- mindspace-pages.mjs | 12 +- mindspace-public-delivery.mjs | 5 + mindspace-public-delivery.test.mjs | 25 +- mindspace-public-image-retry.mjs | 3 +- mindspace-public-page-context.mjs | 54 ++++ mindspace-public-page-context.test.mjs | 75 ++--- mindspace-public-share-widget.mjs | 3 +- mindspace-publications.mjs | 78 ++++- mindspace-published-page-csp.mjs | 40 +++ mindspace-published-page-csp.test.mjs | 25 ++ mindspace-sandbox-mcp.mjs | 68 +++++ mindspace-workspace-page-deliver.mjs | 98 +++++++ mindspace-workspace-page-deliver.test.mjs | 44 +++ mindspace-workspace-path.mjs | 14 + package.json | 2 +- page-data-html-detect.mjs | 89 ++++++ page-data-html-detect.test.mjs | 52 ++++ page-data-workspace-bind.mjs | 239 +++++++++++++++ page-data-workspace-bind.test.mjs | 34 +++ public/assets/page-data-client.js | 24 +- scenarios/ai-usage-survey.json | 43 +++ scenarios/supplier-data-report.json | 27 ++ scenarios/tkmind-feature-survey.json | 32 ++ scripts/check-memory-v2-session-flow.mjs | 101 ++++--- scripts/check-memory-v2-session-flow.test.mjs | 6 +- scripts/dev-core.mjs | 5 +- scripts/repair-child-education-survey.mjs | 101 +++++++ scripts/repair-john-experience-survey.mjs | 97 ++++++ scripts/run-scenario-test.mjs | 18 ++ scripts/scenario-test-lib.mjs | 108 ++++++- scripts/setup-page-data-survey-demo.mjs | 106 +++++++ scripts/verify-publication-delivery.mjs | 160 ++++++++++ server.mjs | 107 ++++--- session-reply-wait.mjs | 3 + skills-registry.mjs | 7 +- skills-registry.test.mjs | 11 + skills/page-data-collect/SKILL.md | 277 ++++++++++++++++++ src/components/ChatPanel.tsx | 6 +- tkmind-proxy.mjs | 23 +- 66 files changed, 3035 insertions(+), 413 deletions(-) create mode 100644 agent-run-deliverable-check.mjs create mode 100644 agent-run-deliverable-check.test.mjs create mode 100644 docs/examples/page-data-survey-test.md create mode 100644 html-document-injection.mjs create mode 100644 html-document-injection.test.mjs create mode 100644 memory-legacy-fallback.mjs create mode 100644 memory-legacy-fallback.test.mjs create mode 100644 mindspace-published-page-csp.mjs create mode 100644 mindspace-published-page-csp.test.mjs create mode 100644 mindspace-workspace-page-deliver.mjs create mode 100644 mindspace-workspace-page-deliver.test.mjs create mode 100644 page-data-html-detect.mjs create mode 100644 page-data-html-detect.test.mjs create mode 100644 page-data-workspace-bind.mjs create mode 100644 page-data-workspace-bind.test.mjs create mode 100644 scenarios/ai-usage-survey.json create mode 100644 scenarios/supplier-data-report.json create mode 100644 scenarios/tkmind-feature-survey.json create mode 100644 scripts/repair-child-education-survey.mjs create mode 100644 scripts/repair-john-experience-survey.mjs create mode 100644 scripts/setup-page-data-survey-demo.mjs create mode 100644 scripts/verify-publication-delivery.mjs create mode 100644 skills/page-data-collect/SKILL.md diff --git a/agent-run-deliverable-check.mjs b/agent-run-deliverable-check.mjs new file mode 100644 index 0000000..f3e9624 --- /dev/null +++ b/agent-run-deliverable-check.mjs @@ -0,0 +1,74 @@ +export const RECOVERABLE_FINISH_ERROR_CODES = new Set([ + 'AGENT_RUN_TIMEOUT', + 'SESSION_REPLY_TIMEOUT', + 'SESSION_REPLY_INCOMPLETE', +]); + +export function isRecoverableFinishError(error) { + const code = String(error?.code ?? '').trim(); + return RECOVERABLE_FINISH_ERROR_CODES.has(code); +} + +export function hasRecoverableSessionDeliverables(summary) { + const pageCount = Number(summary?.pageCount ?? 0); + const publicationCount = Number(summary?.publicationCount ?? 0); + return pageCount > 0 || publicationCount > 0; +} + +export async function detectSessionDeliverables(pool, userId, sessionId) { + if (!pool?.query || !userId || !sessionId) { + return { pageCount: 0, publicationCount: 0, pages: [] }; + } + const [rows] = await pool.query( + `SELECT p.id AS page_id, + p.title, + pr.id AS publication_id, + pr.status AS publication_status, + pr.public_url + FROM h5_page_records p + LEFT JOIN h5_publish_records pr + ON pr.page_id = p.id + AND pr.user_id = p.user_id + AND pr.status = 'online' + WHERE p.user_id = ? + AND p.source_session_id = ? + AND p.status <> 'deleted' + ORDER BY p.created_at ASC`, + [userId, sessionId], + ); + const pages = (rows ?? []).map((row) => ({ + pageId: String(row.page_id ?? '').trim(), + title: String(row.title ?? '').trim(), + publicationId: row.publication_id ? String(row.publication_id) : null, + publicationStatus: row.publication_status ? String(row.publication_status) : null, + publicUrl: row.public_url ? String(row.public_url) : null, + })).filter((row) => row.pageId); + const publicationCount = pages.filter((row) => row.publicationId).length; + return { + pageCount: pages.length, + publicationCount, + pages, + }; +} + +export async function tryRecoverRunFromSessionDeliverables({ + pool, + userId, + sessionId, + error, +} = {}) { + if (!isRecoverableFinishError(error) || !sessionId) { + return null; + } + const deliverables = await detectSessionDeliverables(pool, userId, sessionId); + if (!hasRecoverableSessionDeliverables(deliverables)) { + return null; + } + return { + deliverables, + originalError: { + code: error?.code ?? null, + message: error instanceof Error ? error.message : String(error ?? ''), + }, + }; +} diff --git a/agent-run-deliverable-check.test.mjs b/agent-run-deliverable-check.test.mjs new file mode 100644 index 0000000..ca81c5d --- /dev/null +++ b/agent-run-deliverable-check.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + detectSessionDeliverables, + hasRecoverableSessionDeliverables, + isRecoverableFinishError, + tryRecoverRunFromSessionDeliverables, +} from './agent-run-deliverable-check.mjs'; + +test('isRecoverableFinishError recognizes finish and run timeout codes', () => { + assert.equal(isRecoverableFinishError({ code: 'SESSION_REPLY_INCOMPLETE' }), true); + assert.equal(isRecoverableFinishError({ code: 'SESSION_REPLY_TIMEOUT' }), true); + assert.equal(isRecoverableFinishError({ code: 'AGENT_RUN_TIMEOUT' }), true); + assert.equal(isRecoverableFinishError({ code: 'TOOL_GATEWAY_VALIDATION_FAILED' }), false); +}); + +test('detectSessionDeliverables counts pages and online publications for a session', async () => { + const pool = { + async query(sql, params) { + assert.match(sql, /source_session_id/); + assert.deepEqual(params, ['user-1', 'session-1']); + return [[ + { + page_id: 'page-front', + title: '前台', + publication_id: 'pub-front', + publication_status: 'online', + public_url: 'https://example.com/u/john/pages/page-front', + }, + { + page_id: 'page-admin', + title: '后台', + publication_id: null, + publication_status: null, + public_url: null, + }, + ]]; + }, + }; + const summary = await detectSessionDeliverables(pool, 'user-1', 'session-1'); + assert.equal(summary.pageCount, 2); + assert.equal(summary.publicationCount, 1); + assert.equal(summary.pages[0].publicUrl, 'https://example.com/u/john/pages/page-front'); +}); + +test('tryRecoverRunFromSessionDeliverables succeeds when pages exist despite finish timeout', async () => { + const pool = { + async query() { + return [[{ page_id: 'page-1', title: 'Survey', publication_id: 'pub-1', public_url: '/u/j/x' }]]; + }, + }; + const recovered = await tryRecoverRunFromSessionDeliverables({ + pool, + userId: 'user-1', + sessionId: 'session-1', + error: Object.assign(new Error('session reply timed out'), { code: 'SESSION_REPLY_TIMEOUT' }), + }); + assert.ok(recovered); + assert.equal(recovered.deliverables.pageCount, 1); + assert.equal(recovered.originalError.code, 'SESSION_REPLY_TIMEOUT'); +}); + +test('tryRecoverRunFromSessionDeliverables returns null when no deliverables exist', async () => { + const pool = { async query() { return [[]]; } }; + const recovered = await tryRecoverRunFromSessionDeliverables({ + pool, + userId: 'user-1', + sessionId: 'session-1', + error: Object.assign(new Error('stream ended'), { code: 'SESSION_REPLY_INCOMPLETE' }), + }); + assert.equal(recovered, null); +}); + +test('hasRecoverableSessionDeliverables accepts pages even without online publication', () => { + assert.equal(hasRecoverableSessionDeliverables({ pageCount: 1, publicationCount: 0 }), true); + assert.equal(hasRecoverableSessionDeliverables({ pageCount: 0, publicationCount: 0 }), false); +}); diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index c1f6252..c4e7fd3 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -11,6 +11,7 @@ import { persistSessionTranscriptMessages, } from './conversation-transcript-persist.mjs'; import { ensureGooseUserMessageMetadata } from './goose-message.mjs'; +import { tryRecoverRunFromSessionDeliverables } from './agent-run-deliverable-check.mjs'; const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000]; const TERMINAL_STATUSES = new Set(['succeeded', 'failed']); @@ -242,6 +243,7 @@ export function createAgentRunGateway({ chatIntentRouter = null, sessionSnapshotService = null, conversationMemoryService = null, + syncUserPagesOnSuccess = null, retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS, autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true), maxConcurrentRuns = positiveInteger( @@ -389,6 +391,22 @@ export function createAgentRunGateway({ return projectRun(await getRunById(runId)); } + async function recoverRunFromDeliverables({ runId, userId, sessionId, err }) { + const recovered = await tryRecoverRunFromSessionDeliverables({ + pool, + userId, + sessionId, + error: err, + }); + if (!recovered) return false; + await appendEvent(runId, 'run_recovered_from_deliverables', { + sessionId, + deliverables: recovered.deliverables, + originalError: recovered.originalError, + }); + return true; + } + async function markRun(runId, status, fields = {}) { const updates = ['status = ?', 'updated_at = ?']; const values = [status, nowMs()]; @@ -675,21 +693,33 @@ export function createAgentRunGateway({ && runOptions.toolMode === 'chat' && typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function'; if (awaitSessionFinish) { - const finish = await tkmindProxy.submitSessionReplyAndAwaitFinishForUser( - row.user_id, - sessionId, - row.request_id, - ensureGooseUserMessageMetadata(userMessage), - { - toolMode: runOptions.toolMode, - forceDeepReasoning: runOptions.forceDeepReasoning, - timeoutMs: runTimeoutMs, - }, - ); - await appendEvent(runId, 'session_finished', { - sessionId, - tokenState: finish.tokenState ?? null, - }); + try { + const finish = await tkmindProxy.submitSessionReplyAndAwaitFinishForUser( + row.user_id, + sessionId, + row.request_id, + ensureGooseUserMessageMetadata(userMessage), + { + toolMode: runOptions.toolMode, + forceDeepReasoning: runOptions.forceDeepReasoning, + timeoutMs: runTimeoutMs, + }, + ); + await appendEvent(runId, 'session_finished', { + sessionId, + tokenState: finish.tokenState ?? null, + }); + } catch (err) { + if (await recoverRunFromDeliverables({ + runId, + userId: row.user_id, + sessionId, + err, + })) { + return { sessionId }; + } + throw err; + } } else { await tkmindProxy.submitSessionReplyForUser( row.user_id, @@ -705,6 +735,26 @@ export function createAgentRunGateway({ return { sessionId }; } + async function finalizeSuccessfulRun(runId, row, sessionId) { + await markRun(runId, 'succeeded', { + agent_session_id: sessionId, + completed_at: nowMs(), + error_message: null, + }); + if (typeof syncUserPagesOnSuccess === 'function') { + await syncUserPagesOnSuccess({ + userId: row.user_id, + sessionId, + runId, + }).catch((err) => { + console.warn( + '[AgentRun] workspace page deliver failed:', + err instanceof Error ? err.message : err, + ); + }); + } + } + async function processRun(runId) { const row = await getRunById(runId); if (!row || TERMINAL_STATUSES.has(row.status)) return; @@ -721,11 +771,19 @@ export function createAgentRunGateway({ try { const { sessionId } = await runWithTimeout(runId, () => executeRun(row, runId)); - await markRun(runId, 'succeeded', { - agent_session_id: sessionId, - completed_at: nowMs(), - }); + await finalizeSuccessfulRun(runId, row, sessionId); } catch (err) { + const latest = await getRunById(runId); + const recoverySessionId = latest?.agent_session_id ?? row.agent_session_id ?? null; + if (await recoverRunFromDeliverables({ + runId, + userId: row.user_id, + sessionId: recoverySessionId, + err, + })) { + await finalizeSuccessfulRun(runId, row, recoverySessionId); + return; + } const message = err instanceof Error ? err.message : String(err); const timedOut = err?.code === 'AGENT_RUN_TIMEOUT'; if (timedOut) { diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index fdd6738..2137544 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; import test from 'node:test'; import { createAgentRunGateway } from './agent-run-gateway.mjs'; -function createFakePool() { +function createFakePool({ sessionDeliverables = {} } = {}) { const runs = new Map(); const events = []; @@ -191,6 +191,10 @@ function createFakePool() { }); return [{ affectedRows: 1 }]; } + if (sql.includes('FROM h5_page_records p') && sql.includes('source_session_id')) { + const [userId, sessionId] = params; + return [sessionDeliverables[`${userId}:${sessionId}`] ?? []]; + } if (sql.includes('UPDATE h5_agent_runs SET')) { const id = params.at(-1); const row = runs.get(id); @@ -322,6 +326,53 @@ test('agent run awaits session Finish before succeeding when proxy supports it', assert.equal(finishEvents.length, 1); }); +test('agent run succeeds when Finish is missing but session pages were already created', async () => { + const pool = createFakePool({ + sessionDeliverables: { + 'user-1:session-deliverable-1': [{ + page_id: 'page-front', + title: '供应商数据上报', + publication_id: 'pub-front', + publication_status: 'online', + public_url: 'http://127.0.0.1:5173/u/john/pages/page-front', + }], + }, + }); + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-deliverable-1' }; + }, + async submitSessionReplyAndAwaitFinishForUser() { + const err = new Error('session event stream ended before Finish'); + err.code = 'SESSION_REPLY_INCOMPLETE'; + throw err; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-deliverable-recover', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '生成填报系统' }], + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal( + pool.events.some((event) => event.runId === run.id && event.eventType === 'run_recovered_from_deliverables'), + true, + ); + assert.equal( + pool.events.some((event) => event.runId === run.id && event.eventType === 'session_finished'), + false, + ); +}); + test('agent run uses direct chat service for eligible chat messages', async () => { const pool = createFakePool(); const directRuns = []; diff --git a/capabilities.mjs b/capabilities.mjs index 1531598..f3c722a 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -250,6 +250,10 @@ export function sandboxMcpTools(capabilities) { 'private_data_schema', 'private_data_query', 'private_data_execute', + 'private_data_register_dataset', + 'private_data_set_page_policy', + 'private_data_close_page_dataset', + 'private_data_bind_workspace_page', 'schedule_create_item', 'schedule_create_reminder', 'schedule_list_items', diff --git a/capabilities.test.mjs b/capabilities.test.mjs index b714fe5..2b1dd1d 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -227,6 +227,10 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => { 'private_data_schema', 'private_data_query', 'private_data_execute', + 'private_data_register_dataset', + 'private_data_set_page_policy', + 'private_data_close_page_dataset', + 'private_data_bind_workspace_page', 'schedule_create_item', 'schedule_create_reminder', 'schedule_list_items', @@ -246,6 +250,10 @@ test('private_data_space alone exposes private data tools through sandbox MCP', 'private_data_schema', 'private_data_query', 'private_data_execute', + 'private_data_register_dataset', + 'private_data_set_page_policy', + 'private_data_close_page_dataset', + 'private_data_bind_workspace_page', 'schedule_create_item', 'schedule_create_reminder', 'schedule_list_items', @@ -266,6 +274,10 @@ test('private_data_space alone exposes private data tools through sandbox MCP', 'private_data_schema', 'private_data_query', 'private_data_execute', + 'private_data_register_dataset', + 'private_data_set_page_policy', + 'private_data_close_page_dataset', + 'private_data_bind_workspace_page', 'schedule_create_item', 'schedule_create_reminder', 'schedule_list_items', diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 51963bf..d8e9a8b 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -3,14 +3,17 @@ import { buildWebNewsSkillPrompt, extractSelectedChatSkillName, hasExplicitChatSkillPrompt, + isPageDataIntent, isPageGenerationIntent, isProductCampaignIntent, + PAGE_DATA_COLLECT_SKILL_NAME, } from './chat-skills.mjs'; import { isDirectChatSessionId } from './direct-chat-service.mjs'; import { memoryLimitForIntervention, resolveMemoryInterventionMode, } from './memory-intervention.mjs'; +import { filterMemoriesByQuery } from './memory-legacy-fallback.mjs'; export const CHAT_INTENT_ROUTE = { DIRECT_CHAT: 'direct_chat', @@ -46,6 +49,7 @@ const SKILL_PROMPT_KEYS = { web: 'web', search: 'search', 'static-page-publish': 'generate-page', + 'page-data-collect': 'page-data-collect', 'form-builder': 'form-builder', 'table-viewer': 'table-viewer', 'product-campaign-page': 'product-campaign-page', @@ -66,6 +70,12 @@ const OBVIOUS_DIRECT_PATTERNS = [ /^[??]+$/u, ]; +/** Short confirmations in an ongoing agent session must stay on Agent (not direct chat). */ +const AGENT_SESSION_CONTINUE_PATTERNS = [ + /^(?:可以|好的|好|行|确认|没问题|是的|对|嗯|OK|ok)[!!。.\s]*$/iu, + /^(?:开始吧|按默认做|默认方案|继续|就这样|就这样吧)[!!。.\s]*$/iu, +]; + /** Pure text chat/creative prompts — fast-path even when LLM router is enabled. */ const OBVIOUS_DIRECT_CHAT_PATTERNS = [ /(?:讲|说|来|编).{0,10}(?:个|一段|一首|一个)?(?:睡前故事|故事|笑话|段子)/u, @@ -97,6 +107,8 @@ const MEMORY_RECALL_PATTERNS = [ /(?:我|之前).{0,16}(?:说过|提到|聊过|告诉)/u, /(?:我的|之前的)(?:记忆|偏好|计划|目标|想法)/u, /之前(?:说|提|聊)(?:过|的)/u, + /之前.{0,24}记住/u, + /记住.{0,16}(?:是什么|叫什么|多少|哪个)/u, ]; export function isMemoryRecallQuestion(text) { @@ -105,6 +117,12 @@ export function isMemoryRecallQuestion(text) { return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized)); } +export function isAgentSessionContinueText(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return AGENT_SESSION_CONTINUE_PATTERNS.some((pattern) => pattern.test(normalized)); +} + export function isRealtimeInfoQuestion(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; @@ -135,8 +153,25 @@ function buildRealtimeInfoClassification() { }, { source: 'rule' }); } +export function coercePageDataSkill(classification, text, { grantedSkills = [] } = {}) { + if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification; + if (!isPageDataIntent(text)) return classification; + if (grantedSkills.length > 0 && !grantedSkills.includes(PAGE_DATA_COLLECT_SKILL_NAME)) { + return classification; + } + + return { + ...classification, + suggestedSkill: PAGE_DATA_COLLECT_SKILL_NAME, + agentBrief: + '在 MindSpace 页面实现数据收集与持久化:建表、注册 dataset、配置 page policy、HTML 使用 page-data-client.js;禁止自建后端服务。', + reason: `${classification.reason}(页面数据交互意图)`, + }; +} + export function coercePageGenerationSkill(classification, text, { grantedSkills = [] } = {}) { if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification; + if (isPageDataIntent(text)) return classification; if (!isPageGenerationIntent(text) || isProductCampaignIntent(text)) return classification; if (grantedSkills.length > 0 && !grantedSkills.includes('static-page-publish')) return classification; @@ -321,7 +356,8 @@ function buildRouterSystemPrompt(grantedSkills = []) { : 'suggested_skill 通常填 null。', '', 'skill 选择补充:', - '- static-page-publish:攻略/游记/主题内容页、城市介绍、活动介绍、任何「做页面/生成链接」且无商品购买跳转需求。', + '- page-data-collect:页面需要问卷/表单/报名/提交记录/后台查看/数据交互/SQLite 持久化;必须用 Page Data API,禁止自建 Express 或独立端口。', + '- static-page-publish:攻略/游记/主题内容页、城市介绍、活动介绍、任何「做页面/生成链接」且无数据持久化需求。', '- product-campaign-page:仅当用户明确提供商品链接或要求购买按钮/电商转化时使用,不要用于旅游攻略或纯内容页。', '', '只输出 JSON,不要 markdown,不要解释:', @@ -740,10 +776,10 @@ export function classifyWithRules({ }, { source: 'rule' }), decisionContext); } if ( - !llmRouterEnabled && includeIntentPatterns && normalized && - isMemoryRecallQuestion(normalized) + isMemoryRecallQuestion(normalized) && + !hasPriorAgentConversation(sessionId, sessionMessageCount) ) { return finalizeRouterClassification(normalizeClassification({ route: CHAT_INTENT_ROUTE.DIRECT_CHAT, @@ -751,11 +787,28 @@ export function classifyWithRules({ reason: '用户在询问个人记忆或历史对话', }, { source: 'rule' }), decisionContext); } - if (!llmRouterEnabled && hasPriorAgentConversation(sessionId, sessionMessageCount)) { + if (hasPriorAgentConversation(sessionId, sessionMessageCount)) { + const reason = isAgentSessionContinueText(normalized) + ? 'Agent 会话确认/续聊' + : '延续已有 Agent 会话'; return finalizeRouterClassification(normalizeClassification({ route: CHAT_INTENT_ROUTE.AGENT, confidence: 1, - reason: '延续已有 Agent 会话', + reason, + }, { source: 'rule' }), decisionContext); + } + if ( + includeIntentPatterns && + normalized && + isPageDataIntent(normalized) + ) { + return finalizeRouterClassification(normalizeClassification({ + route: CHAT_INTENT_ROUTE.AGENT, + confidence: 0.96, + reason: '页面需要数据交互与持久化', + suggested_skill: PAGE_DATA_COLLECT_SKILL_NAME, + agent_brief: + '使用 Page Data API 实现页面表单提交与后台查看;建表、注册 dataset、配置 policy,HTML 引入 page-data-client.js。', }, { source: 'rule' }), decisionContext); } if ( @@ -813,6 +866,7 @@ export function createChatIntentRouter(options = {}) { const { llmProviderService, memoryV2 = null, + conversationMemoryService = null, env = process.env, logger = console, } = options; @@ -849,7 +903,8 @@ export function createChatIntentRouter(options = {}) { } function isEnabled() { - return Boolean(policy.enabled && llmProviderService?.createChatCompletion); + // Skill + rule routing only; LLM intent router is intentionally disabled. + return false; } async function resolveRouterContext({ userId, sessionId, text, forceDeepReasoning = false }) { @@ -861,32 +916,50 @@ export function createChatIntentRouter(options = {}) { if ( limit <= 0 || !policy.memoryResolveEnabled || - !memoryV2?.resolve || - !userId + !userId || + (!memoryV2?.resolve && !conversationMemoryService?.listMemories) ) { return buildRouterContext(null); } - try { - const resolved = await withTimeout( - memoryV2.resolve({ - userId, - sessionId, - query: text, - limit, - }), - policy.timeoutMs, - 'Memory V2 router resolve', - ); - return buildRouterContext(resolved); - } catch (err) { - logger?.warn?.( - `[chat-intent-router] memory resolve skipped: ${err instanceof Error ? err.message : err}`, - ); + let primaryFailed = false; + let memories = []; + const recallQuestion = isMemoryRecallQuestion(text); + if (recallQuestion && conversationMemoryService?.listMemories) { + const legacy = await conversationMemoryService.listMemories(userId, { limit }).catch(() => []); + memories = filterMemoriesByQuery(legacy, text); + } + if (!memories.length && memoryV2?.resolve) { + try { + const resolved = await withTimeout( + memoryV2.resolve({ + userId, + sessionId, + query: text, + limit, + }), + policy.timeoutMs, + 'Memory V2 router resolve', + ); + memories = Array.isArray(resolved?.memories) ? resolved.memories : []; + } catch (err) { + primaryFailed = true; + logger?.warn?.( + `[chat-intent-router] memory resolve skipped: ${err instanceof Error ? err.message : err}`, + ); + } + } + if (!memories.length && conversationMemoryService?.listMemories) { + const legacy = await conversationMemoryService.listMemories(userId, { limit }).catch(() => []); + memories = filterMemoriesByQuery(legacy, text); + if (memories.length) primaryFailed = false; + } + if (primaryFailed && !memories.length) { return buildRouterContext({ degraded: true, reason: 'memory_resolve_failed', }); } + return buildRouterContext({ memories, source: 'router-resolve' }); } async function classify({ @@ -912,7 +985,11 @@ export function createChatIntentRouter(options = {}) { delete base.decision; return finalizeRouterClassification( coercePageGenerationSkill( - coerceRealtimeWebSkill(base, text, { grantedSkills }), + coercePageDataSkill( + coerceRealtimeWebSkill(base, text, { grantedSkills }), + text, + { grantedSkills }, + ), text, { grantedSkills }, ), @@ -927,89 +1004,14 @@ export function createChatIntentRouter(options = {}) { sessionMessageCount, userMessage, includeIntentPatterns: true, - llmRouterEnabled: Boolean(policy.enabled), + llmRouterEnabled: false, }); if (ruleResult) return finalizeWithCoercion(ruleResult); - if (!isEnabled()) { - return finalizeWithCoercion(normalizeClassification({ - route: policy.fallbackRoute, - confidence: 0.5, - reason: '意图路由未启用,走默认通道', - }, { source: 'fallback' })); - } - - const routerContext = await resolveRouterContext({ - userId, - sessionId, - text, - forceDeepReasoning, - }); - let completion = null; - try { - completion = await withTimeout( - llmProviderService.createChatCompletion({ - providerKeyId: policy.modelProviderKeyId || undefined, - model: policy.model || undefined, - modelApiType: policy.modelApiType || undefined, - temperature: policy.temperature, - messages: [ - { role: 'system', content: buildRouterSystemPrompt(grantedSkills) }, - { - role: 'user', - content: buildRouterUserPrompt({ - text, - routerContext: routerContext.content, - }), - }, - ], - }), - policy.timeoutMs, - 'Chat intent router', - ); - } catch (err) { - return finalizeWithCoercion(normalizeClassification({ - route: policy.fallbackRoute, - confidence: 0, - reason: err instanceof Error ? err.message : '意图路由失败,走默认通道', - memory: routerContext, - }, { source: 'fallback', fallbackRoute: policy.fallbackRoute })); - } - if (!completion?.ok) { - return finalizeWithCoercion(normalizeClassification({ - route: policy.fallbackRoute, - confidence: 0, - reason: completion?.message ?? '意图路由失败,走默认通道', - memory: routerContext, - }, { source: 'fallback', fallbackRoute: policy.fallbackRoute })); - } - - const parsed = parseRouterJson(completion.reply); - if (!parsed) { - return finalizeWithCoercion(normalizeClassification({ - route: policy.fallbackRoute, - confidence: 0, - reason: '意图路由响应无法解析,走默认通道', - memory: routerContext, - }, { source: 'fallback', fallbackRoute: policy.fallbackRoute })); - } - - const classification = finalizeWithCoercion({ - ...normalizeClassification(parsed, { source: 'llm', fallbackRoute: policy.fallbackRoute }), - providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId ?? null, - model: completion.model ?? policy.model ?? null, - memory: routerContext, - }); - if ( - classification.route === CHAT_INTENT_ROUTE.DIRECT_CHAT && - classification.confidence < policy.minConfidence - ) { - return finalizeWithCoercion(normalizeClassification({ - ...classification, - route: policy.fallbackRoute, - reason: `${classification.reason}(置信度 ${classification.confidence} 低于阈值,走默认通道)`, - }, { source: 'threshold', fallbackRoute: policy.fallbackRoute })); - } - return classification; + return finalizeWithCoercion(normalizeClassification({ + route: policy.fallbackRoute, + confidence: 0.5, + reason: '规则未命中,走 skill 默认 Agent 通道', + }, { source: 'fallback' })); } return { @@ -1023,6 +1025,7 @@ export function createChatIntentRouter(options = {}) { export function createManagedChatIntentRouter({ llmProviderService, memoryV2 = null, + conversationMemoryService = null, configService = null, env = process.env, logger = console, @@ -1076,6 +1079,7 @@ export function createManagedChatIntentRouter({ activeRouter = createChatIntentRouter({ llmProviderService, memoryV2, + conversationMemoryService, env: state.effectiveEnv, logger, }); diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index b3118e8..374cea7 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -7,6 +7,7 @@ import { buildRouterNormalizedDecision, CHAT_INTENT_ROUTE, classifyWithRules, + coercePageDataSkill, coercePageGenerationSkill, createChatIntentRouter, createManagedChatIntentRouter, @@ -52,6 +53,51 @@ test('classifyWithRules routes page generation to agent orchestration', () => { assert.equal(result.suggestedSkill, 'static-page-publish'); }); +test('classifyWithRules routes page data collection to page-data-collect', () => { + const text = '帮我在页面增加调查问卷,密码 888 后台查看每个用户提交记录,要用 sqlite'; + const result = classifyWithRules({ + text, + userMessage: { + role: 'user', + content: [{ type: 'text', text }], + metadata: { displayText: text }, + }, + }); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.suggestedSkill, 'page-data-collect'); +}); + +test('coercePageDataSkill overrides static-page-publish when data intent is present', () => { + const text = '帮我在这个页面增加调查问卷,密码 888 查看提交记录'; + const coerced = coercePageDataSkill( + { + route: CHAT_INTENT_ROUTE.AGENT, + suggestedSkill: 'static-page-publish', + reason: '明确需要生成或发布页面/文件', + agentBrief: '生成页面', + }, + text, + { grantedSkills: ['page-data-collect', 'static-page-publish'] }, + ); + assert.equal(coerced.suggestedSkill, 'page-data-collect'); + assert.match(coerced.reason, /页面数据交互意图/); +}); + +test('coercePageGenerationSkill skips when page data intent is present', () => { + const text = '帮我在页面增加调查问卷和后台入口'; + const coerced = coercePageGenerationSkill( + { + route: CHAT_INTENT_ROUTE.AGENT, + suggestedSkill: null, + reason: '任务编排', + agentBrief: '', + }, + text, + { grantedSkills: ['static-page-publish', 'page-data-collect'] }, + ); + assert.notEqual(coerced.suggestedSkill, 'static-page-publish'); +}); + test('classifyWithRules routes implicit travel guide page requests to static-page-publish', () => { const result = classifyWithRules({ text: '苏州攻略页面', @@ -220,14 +266,15 @@ test('classifyWithRules honors forceDeepReasoning bypass', () => { assert.match(result.reason, /深度推理/); }); -test('classifyWithRules skips session continuation when llm router is enabled', () => { +test('classifyWithRules keeps agent session continuation even when llm router flag is true', () => { const continued = classifyWithRules({ - text: '我想去日本玩', - sessionId: '20260704_10', - sessionMessageCount: 2, + text: '可以', + sessionId: '20260708_64', + sessionMessageCount: 4, llmRouterEnabled: true, }); - assert.equal(continued, null); + assert.equal(continued.route, CHAT_INTENT_ROUTE.AGENT); + assert.match(continued.reason, /Agent 会话确认|延续已有 Agent 会话/); }); test('classifyWithRules keeps empty pre-created sessions eligible for llm routing', () => { @@ -248,7 +295,7 @@ test('classifyWithRules keeps empty pre-created sessions eligible for llm routin assert.match(fresh.reason, /记忆/); }); -test('classifyWithRules routes memory recall to direct chat even in agent session', () => { +test('classifyWithRules keeps memory recall on agent when session already active', () => { const result = classifyWithRules({ text: '你记得我说想去哪儿吗', sessionId: '20260704_9', @@ -259,8 +306,8 @@ test('classifyWithRules routes memory recall to direct chat even in agent sessio metadata: { displayText: '你记得我说想去哪儿吗' }, }, }); - assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); - assert.match(result.reason, /记忆/); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.match(result.reason, /延续已有 Agent 会话/); }); test('classifyWithRules bypasses llm router when user selected summarize skill', async () => { @@ -327,7 +374,7 @@ test('classifyWithRules bypasses llm router for explicit web skill prompt', asyn assert.equal(llmCalls.length, 0); }); -test('createChatIntentRouter classifies continued sessions through llm when enabled', async () => { +test('createChatIntentRouter keeps continued sessions on agent without llm router', async () => { const llmCalls = []; const router = createChatIntentRouter({ enabled: true, @@ -336,16 +383,7 @@ test('createChatIntentRouter classifies continued sessions through llm when enab llmProviderService: { async createChatCompletion(payload) { llmCalls.push(payload); - return { - ok: true, - reply: JSON.stringify({ - route: 'agent_orchestration', - confidence: 0.88, - reason: '用户要规划日本旅行,需要搜索和生成内容', - suggested_skill: 'static-page-publish', - agent_brief: '整理日本旅行攻略', - }), - }; + return { ok: false, message: 'router disabled' }; }, }, memoryV2: { @@ -366,14 +404,13 @@ test('createChatIntentRouter classifies continued sessions through llm when enab }, }); - assert.equal(llmCalls.length, 1); + assert.equal(llmCalls.length, 0); assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); - assert.equal(result.source, 'llm'); - assert.match(result.reason, /日本旅行/); - assert.equal(result.memory?.itemsUsed ?? 0, 0); + assert.equal(result.source, 'rule'); + assert.match(result.reason, /延续已有 Agent 会话/); }); -test('createChatIntentRouter skips memory resolve for general conversational analysis', async () => { +test('createChatIntentRouter uses agent fallback for general conversational analysis (skill-only)', async () => { const llmCalls = []; const resolveCalls = []; const router = createChatIntentRouter({ @@ -383,18 +420,7 @@ test('createChatIntentRouter skips memory resolve for general conversational ana llmProviderService: { async createChatCompletion({ messages, providerKeyId, model, temperature }) { llmCalls.push({ messages, providerKeyId, model, temperature }); - return { - ok: true, - providerKeyId, - model, - reply: JSON.stringify({ - route: 'direct_chat', - confidence: 0.91, - reason: '用户在咨询概念,不需要执行工具', - suggested_skill: null, - agent_brief: '', - }), - }; + return { ok: false, message: 'router disabled' }; }, }, memoryV2: { @@ -419,37 +445,19 @@ test('createChatIntentRouter skips memory resolve for general conversational ana grantedSkills: ['web'], }); - assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); - assert.equal(result.source, 'llm'); - assert.equal(result.providerKeyId, 'key-router'); - assert.equal(result.model, 'deepseek-chat'); - assert.equal(result.memory?.itemsUsed ?? 0, 0); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'fallback'); assert.equal(resolveCalls.length, 0); - assert.equal(llmCalls.length, 1); - assert.equal(llmCalls[0].providerKeyId, 'key-router'); - assert.equal(llmCalls[0].model, 'deepseek-chat'); - assert.equal(llmCalls[0].temperature, 0); - assert.doesNotMatch(llmCalls[0].messages[1].content, /相关记忆/); + assert.equal(llmCalls.length, 0); }); -test('createChatIntentRouter resolves light memory for recall questions', async () => { +test('createChatIntentRouter routes memory recall through rules on fresh session', async () => { const resolveCalls = []; const router = createChatIntentRouter({ enabled: true, - modelProviderKeyId: 'key-router', - model: 'deepseek-chat', llmProviderService: { async createChatCompletion() { - return { - ok: true, - reply: JSON.stringify({ - route: 'direct_chat', - confidence: 0.95, - reason: '用户在询问个人记忆', - suggested_skill: null, - agent_brief: '', - }), - }; + return { ok: false, message: 'router disabled' }; }, }, memoryV2: { @@ -473,9 +481,8 @@ test('createChatIntentRouter resolves light memory for recall questions', async }); assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); - assert.equal(resolveCalls.length, 1); - assert.equal(resolveCalls[0].limit, 3); - assert.equal(result.memory?.itemsUsed, 1); + assert.equal(result.source, 'rule'); + assert.equal(resolveCalls.length, 0); }); test('buildRouterContext trims rich Memory V2 resolve payload for routing', () => { @@ -499,22 +506,12 @@ test('buildRouterContext trims rich Memory V2 resolve payload for routing', () = assert.doesNotMatch(context.content, /ignored/); }); -test('createChatIntentRouter fails open when Memory V2 resolve fails', async () => { +test('createChatIntentRouter routes memory recall through rules when memory resolve fails', async () => { const router = createChatIntentRouter({ enabled: true, llmProviderService: { - async createChatCompletion({ messages }) { - assert.match(messages[1].content, /\[Router Context\]\n无/); - return { - ok: true, - reply: JSON.stringify({ - route: 'agent_orchestration', - confidence: 0.82, - reason: '需要工具执行', - suggested_skill: 'web', - agent_brief: '搜索并整理来源', - }), - }; + async createChatCompletion() { + return { ok: false, message: 'router disabled' }; }, }, memoryV2: { @@ -535,11 +532,11 @@ test('createChatIntentRouter fails open when Memory V2 resolve fails', async () grantedSkills: ['web'], }); - assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); - assert.equal(result.memory.degraded, true); + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.equal(result.source, 'rule'); }); -test('createManagedChatIntentRouter hot-loads admin config and stays closed by default', async () => { +test('createManagedChatIntentRouter hot-loads admin config and stays skill-only', async () => { const states = [ { fingerprint: 'off', @@ -584,7 +581,7 @@ test('createManagedChatIntentRouter hot-loads admin config and stays closed by d assert.equal(await router.isEnabled(), false); states.shift(); - assert.equal(await router.isEnabled(), true); + assert.equal(await router.isEnabled(), false); const result = await router.classify({ userMessage: { role: 'user', @@ -592,16 +589,19 @@ test('createManagedChatIntentRouter hot-loads admin config and stays closed by d metadata: { displayText: '帮我概括一下这段材料的要点' }, }, }); - assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); - assert.equal(llmCalls[0].providerKeyId, 'key-router'); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'fallback'); + assert.equal(llmCalls.length, 0); }); -test('createChatIntentRouter escalates low-confidence direct chat to agent', async () => { +test('createChatIntentRouter uses agent fallback when rules miss (skill-only)', async () => { + const llmCalls = []; const router = createChatIntentRouter({ enabled: true, minConfidence: 0.8, llmProviderService: { async createChatCompletion() { + llmCalls.push(1); return { ok: true, reply: JSON.stringify({ @@ -625,14 +625,17 @@ test('createChatIntentRouter escalates low-confidence direct chat to agent', asy }); assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); - assert.equal(result.source, 'threshold'); + assert.equal(result.source, 'fallback'); + assert.equal(llmCalls.length, 0); }); -test('createChatIntentRouter falls back to agent when LLM fails', async () => { +test('createChatIntentRouter falls back to agent when rules miss (skill-only)', async () => { + const llmCalls = []; const router = createChatIntentRouter({ enabled: true, llmProviderService: { async createChatCompletion() { + llmCalls.push(1); return { ok: false, message: 'router model unavailable' }; }, }, @@ -649,6 +652,17 @@ test('createChatIntentRouter falls back to agent when LLM fails', async () => { assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); assert.equal(result.source, 'fallback'); + assert.equal(llmCalls.length, 0); +}); + +test('classifyWithRules routes memory recall to direct chat on fresh session', () => { + const result = classifyWithRules({ + text: '你记得我说想去哪儿吗', + sessionId: '20260704_3', + sessionMessageCount: 0, + }); + assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); + assert.match(result.reason, /记忆/); }); test('createChatIntentRouter fast-paths news lookup to agent without LLM', async () => { @@ -867,12 +881,12 @@ test('shadow mode keeps legacy route for gateway decisions', () => { } }); -test('classifyWithRules fast-paths bedtime story when llm router is enabled', () => { +test('classifyWithRules fast-paths bedtime story on fresh session', () => { const result = classifyWithRules({ text: '讲一个睡前故事吧', llmRouterEnabled: true, sessionId: '20260706_2', - sessionMessageCount: 3, + sessionMessageCount: 0, userMessage: { role: 'user', content: [{ type: 'text', text: '讲一个睡前故事吧' }], @@ -1066,21 +1080,14 @@ test('logRouterDecisionShadow emits payload only in shadow mode', () => { } }); -test('createChatIntentRouter accepts chat and agent route synonyms from llm', async () => { +test('createChatIntentRouter uses agent fallback for unmatched general questions (skill-only)', async () => { + const llmCalls = []; const router = createChatIntentRouter({ enabled: true, llmProviderService: { async createChatCompletion() { - return { - ok: true, - reply: JSON.stringify({ - route: 'chat', - confidence: 0.92, - reason: '纯问答', - suggested_skill: null, - agent_brief: '', - }), - }; + llmCalls.push(1); + return { ok: false, message: 'router disabled' }; }, }, }); @@ -1093,6 +1100,7 @@ test('createChatIntentRouter accepts chat and agent route synonyms from llm', as }, }); - assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT); - assert.equal(result.decision.route, ROUTER_DECISION_ROUTE.CHAT); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.equal(result.source, 'fallback'); + assert.equal(llmCalls.length, 0); }); diff --git a/chat-skills.mjs b/chat-skills.mjs index c2679cb..73a159e 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -1,5 +1,6 @@ // Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url). const PUBLISH_SKILL_NAME = 'static-page-publish'; +export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect'; const WEB_INTENT_PATTERNS = [ /(?:今天|今日|最新|热点|热搜|新闻|头条|头条新闻|最新消息|发生了什么|最近发生)/u, @@ -27,6 +28,22 @@ const PRODUCT_CAMPAIGN_INTENT_PATTERNS = [ /(?:https?:\/\/|taobao|tmall|jd\.com|商品链接|购买链接|购买按钮)/iu, ]; +const PAGE_DATA_INTENT_PATTERNS = [ + /(?:问卷|调查|签到表|签到登记|签到收集|投票|意见反馈|数据上报)/u, + /(?:报名表|报名登记|在线报名|收集报名)/u, + /(?:表单|数据采集|数据交互|存数据|保存提交|提交记录)/u, + /(?:后台|管理入口|管理后台).{0,20}(?:查看|记录|数据|提交)/u, + /(?:密码|口令).{0,12}(?:查看|后台|管理|进入)/u, + /(?:sqlite|数据库|page[\s-]?data)/i, + /(?:每个用户|各用户).{0,12}(?:提交|记录)/u, +]; + +export function isPageDataIntent(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return PAGE_DATA_INTENT_PATTERNS.some((pattern) => pattern.test(normalized)); +} + export function isPageGenerationIntent(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; @@ -76,6 +93,15 @@ export const CHAT_SKILL_DEFINITIONS = [ prefillOnly: true, promptKey: 'form-builder', }, + { + id: 'page-data-collect', + label: '数据页面', + icon: 'form', + skillName: PAGE_DATA_COLLECT_SKILL_NAME, + requiresSkill: PAGE_DATA_COLLECT_SKILL_NAME, + requiresPublish: true, + promptKey: 'page-data-collect', + }, { id: 'table-view', label: '数据表格', @@ -125,6 +151,13 @@ export function buildChatSkillPrompt(promptKey, skillName) { return `请使用 ${skillName ?? 'search'} 技能:帮我在工作区中查找代码或文件。我要找的是:`; case 'form-builder': return `请使用 ${skillName ?? 'form-builder'} 技能:请用交互式表单收集以下场景所需的结构化字段(字段不超过 8 个):`; + case 'page-data-collect': + return ( + `请使用 ${skillName ?? PAGE_DATA_COLLECT_SKILL_NAME} 技能:在 MindSpace 页面中实现可提交、可持久化的数据收集(问卷/报名/台账等),必须使用 Page Data API。` + + '先匹配技能内能力分支(默认 A:匿名前台 public insert + 独立后台 password read,口令默认 88888888);展示方案摘要确认后再开工。' + + '流程:load_skill → private_data_execute 建表 → private_data_register_dataset → write_file/edit_file 写 public/*.html(含 /assets/page-data-client.js)→ private_data_bind_workspace_page 发布并写策略。' + + '禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后返回 workspaceUrl,并说明后台入口与口令。' + ); case 'service-integration-smoke': return `请使用 ${skillName ?? 'service-integration-smoke'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`; case 'table-viewer': @@ -160,7 +193,12 @@ export { buildWebNewsSkillPrompt }; export function filterChatSkills(options, ctx) { return options.filter((skill) => { - if (skill.requiresPublish && !ctx.canPublish) return false; + if (skill.requiresPublish && !ctx.canPublish) { + const pageDataGranted = + skill.skillName === PAGE_DATA_COLLECT_SKILL_NAME && + ctx.grantedSkills?.includes(PAGE_DATA_COLLECT_SKILL_NAME); + if (!pageDataGranted) return false; + } if (skill.requiresSkill && !ctx.grantedSkills?.includes(skill.requiresSkill)) return false; return true; }); @@ -169,6 +207,12 @@ export function filterChatSkills(options, ctx) { export function buildAutoChatSkillPrefix(text, grantedSkills = []) { const trimmed = String(text ?? '').trim(); if (!trimmed) return ''; + if ( + grantedSkills.includes(PAGE_DATA_COLLECT_SKILL_NAME) && + isPageDataIntent(trimmed) + ) { + return buildChatSkillPrompt('page-data-collect', PAGE_DATA_COLLECT_SKILL_NAME); + } if (grantedSkills.includes(PUBLISH_SKILL_NAME) && isPageGenerationIntent(trimmed)) { return buildChatSkillPrompt('generate-page', PUBLISH_SKILL_NAME); } diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 3c6c029..4ad38f6 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -5,6 +5,7 @@ import { buildChatSkillPrompt, CHAT_SKILL_DEFINITIONS, filterChatSkills, + isPageDataIntent, isPageGenerationIntent, } from './chat-skills.mjs'; @@ -43,6 +44,14 @@ test('filterChatSkills shows service integration smoke when granted', () => { assert.ok(visible.some((item) => item.id === 'service-integration-smoke')); }); +test('filterChatSkills shows page-data-collect when granted without static publish', () => { + const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { + canPublish: false, + grantedSkills: ['page-data-collect'], + }); + assert.ok(visible.some((item) => item.id === 'page-data-collect')); +}); + test('filterChatSkills shows generate-page when publish is allowed', () => { const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: true }); assert.ok(visible.some((item) => item.id === 'generate-page')); @@ -57,6 +66,8 @@ test('buildChatSkillPrompt includes skill name for platform skills', () => { assert.match(buildChatSkillPrompt('generate-page'), /public\/\*\.docx/); assert.match(buildChatSkillPrompt('generate-page'), /long-image-download/); assert.match(buildChatSkillPrompt('generate-page'), /generate_long_image/); + assert.match(buildChatSkillPrompt('page-data-collect'), /Page Data API/); + assert.match(buildChatSkillPrompt('page-data-collect'), /禁止自建 Express/); }); test('prefillOnly is set for open-ended chat skills', () => { @@ -87,6 +98,18 @@ test('isPageGenerationIntent matches implicit travel guide page requests', () => assert.equal(isPageGenerationIntent('你好'), false); }); +test('buildAutoChatSkillPrefix prefers page-data-collect for survey requests', () => { + const text = '帮我在这个页面增加一个调查问卷,出三个问题,密码 888 查看提交记录'; + const prefix = buildAutoChatSkillPrefix(text, ['page-data-collect', 'static-page-publish']); + assert.match(prefix, /page-data-collect/); + assert.doesNotMatch(prefix, /static-page-publish 技能:在我的专属 MindSpace 发布目录生成静态 HTML/); +}); + +test('isPageDataIntent matches survey and backend keywords', () => { + assert.equal(isPageDataIntent('增加调查问卷和后台入口,密码 888'), true); + assert.equal(isPageDataIntent('帮我做一个苏州攻略页面'), false); +}); + test('buildAutoChatSkillPrefix enables publish for implicit page requests', () => { const prefix = buildAutoChatSkillPrefix('苏州攻略页面', ['static-page-publish']); assert.match(prefix, /static-page-publish/); diff --git a/conversation-memory.mjs b/conversation-memory.mjs index 8e3307a..9c67a34 100644 --- a/conversation-memory.mjs +++ b/conversation-memory.mjs @@ -139,6 +139,7 @@ function fallbackMemoriesFromMessages(messages) { { label: 'goal', re: /(?:我的目标是|我想要|我希望|我打算|我计划|我想去|打算去|准备去)(.+)/ }, { label: 'habit', re: /(?:我通常|我习惯|我一般)(.+)/ }, { label: 'fact', re: /(?:我是|我叫|我来自|我在)(.{2,40})/ }, + { label: 'fact', re: /(?:请记住|记住).{0,12}(?:别名|叫做|是)([^。!?\s]{2,40})/ }, { label: 'experience', re: /(?:我们|我).{0,8}(?:去|到|在).{2,40}(?:玩|旅游|旅行|出差|度假)/ }, ]; for (const message of messages) { @@ -369,10 +370,10 @@ export function createConversationMemoryService(pool, options = {}) { return rows.length; } - async function analyzeUser(userId) { - if (!isEnabled() || !pool || !userId) return { ok: false, reason: 'disabled' }; - const messages = await loadUnanalyzedUserMessages(userId); - if (!messages.length) return { ok: true, analyzed: 0, memories: 0 }; + async function analyzeMessageBatch(userId, messages) { + if (!isEnabled() || !pool || !userId || !messages.length) { + return { ok: true, analyzed: 0, memories: 0 }; + } let memories = null; if (llmEnabled()) { try { @@ -381,18 +382,35 @@ export function createConversationMemoryService(pool, options = {}) { warnLlmExtractionFailed(err); } } - if (!memories) memories = fallbackMemoriesFromMessages(messages); + if (!memories?.length) memories = fallbackMemoriesFromMessages(messages); const stored = await storeMemories(userId, messages, memories); - // Always mark the batch processed after one pass so transient LLM failures - // do not leave messages permanently stuck in the analyze queue. await markAnalyzed(messages.map((message) => message.id)); return { ok: true, analyzed: messages.length, memories: stored }; } + async function analyzeUser(userId) { + if (!isEnabled() || !pool || !userId) return { ok: false, reason: 'disabled' }; + const messages = await loadUnanalyzedUserMessages(userId); + if (!messages.length) return { ok: true, analyzed: 0, memories: 0 }; + return analyzeMessageBatch(userId, messages); + } + async function saveAndAnalyze(sessionId, userId, messages = []) { const saved = await saveConversationMessages(sessionId, userId, messages); if (!saved.length) return { saved: 0, analyzed: 0, memories: 0 }; - const result = await analyzeUser(userId); + const userSaved = saved + .filter((message) => message.role === 'user') + .map((message) => ({ + id: message.id, + user_id: message.userId, + agent_session_id: message.sessionId, + message_key: message.messageKey, + sequence_no: message.sequenceNo, + role: message.role, + text: message.text, + created_at: message.createdAt, + })); + const result = await analyzeMessageBatch(userId, userSaved); return { saved: saved.length, analyzed: result.analyzed ?? 0, diff --git a/conversation-memory.test.mjs b/conversation-memory.test.mjs index 1645a1c..8ec532a 100644 --- a/conversation-memory.test.mjs +++ b/conversation-memory.test.mjs @@ -152,6 +152,32 @@ test('saveAndAnalyze stores messages and fallback memories', async () => { else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; }); +test('saveAndAnalyze re-analyzes saved session messages even when already marked analyzed', async () => { + const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0'; + const pool = createPool(); + const service = createConversationMemoryService(pool, { now: () => 1500 }); + const messages = [{ + id: 'm-remember', + role: 'user', + content: [{ type: 'text', text: '请记住:我的测试别名是蓝鲸42。只回复已记住。' }], + metadata: { userVisible: true }, + }]; + + const first = await service.saveAndAnalyze('session-remember', 'user-remember', messages); + assert.equal(first.analyzed, 1); + assert.equal(first.memories, 1); + assert.match(pool.state.memories.at(-1).memory_text, /蓝鲸42/); + + pool.state.messages[0].analyzed_at = 1500; + const second = await service.saveAndAnalyze('session-remember', 'user-remember', messages); + assert.equal(second.analyzed, 1); + assert.equal(second.memories, 1); + + if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; + else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous; +}); + test('saveAndAnalyze marks messages analyzed when llm extraction fails and no memory is stored', async () => { const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED; process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1'; diff --git a/conversation-repair.mjs b/conversation-repair.mjs index 735af20..2058f34 100644 --- a/conversation-repair.mjs +++ b/conversation-repair.mjs @@ -42,6 +42,11 @@ export function parseStoredConversationRow(row) { }; } +export function filterUserVisibleConversation(messages) { + if (!Array.isArray(messages)) return []; + return messages.filter((message) => message?.metadata?.userVisible !== false); +} + export function countNonEmptyConversationMessages(messages) { if (!Array.isArray(messages)) return 0; return messages.filter((message) => { diff --git a/conversation-repair.test.mjs b/conversation-repair.test.mjs index 60f4ea8..7d18d19 100644 --- a/conversation-repair.test.mjs +++ b/conversation-repair.test.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { buildConversationFromDbRows, countNonEmptyConversationMessages, + filterUserVisibleConversation, parseStoredConversationRow, repairConversationFromDbRows, shouldRepairConversationFromDb, @@ -84,3 +85,15 @@ test('buildConversationFromDbRows dedupes by message_key', () => { assert.equal(built.length, 1); assert.equal(built[0].content[0].text, 'second'); }); + +test('filterUserVisibleConversation keeps messages without explicit userVisible flag', () => { + const messages = [ + { role: 'user', content: [{ type: 'text', text: '请记住别名' }] }, + { role: 'assistant', metadata: { userVisible: true }, content: [{ type: 'text', text: '已记住' }] }, + { role: 'system', metadata: { userVisible: false }, content: [{ type: 'text', text: 'hidden' }] }, + ]; + const visible = filterUserVisibleConversation(messages); + assert.equal(visible.length, 2); + assert.equal(visible[0].role, 'user'); + assert.equal(visible[1].role, 'assistant'); +}); diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs index 48fb251..3568468 100644 --- a/direct-chat-service.mjs +++ b/direct-chat-service.mjs @@ -1,5 +1,7 @@ import crypto from 'node:crypto'; import { MEMORY_INTERVENTION_LIMIT } from './memory-intervention.mjs'; +import { resolveMemoriesWithLegacyFallback } from './memory-legacy-fallback.mjs'; +import { isMemoryRecallQuestion } from './chat-intent-router.mjs'; import { resolveSessionAccess } from './session-broker.mjs'; export const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_'; @@ -294,15 +296,15 @@ export function createDirectChatService({ } async function resolveMemories(userId, sessionId, query, { limit = MEMORY_INTERVENTION_LIMIT.LIGHT_DIRECT_CHAT } = {}) { - if (!userId || limit <= 0) return []; - if (memoryV2?.resolve) { - const result = await memoryV2.resolve({ userId, sessionId, query, limit }).catch(() => null); - return Array.isArray(result?.memories) ? result.memories : []; - } - if (conversationMemoryService?.listMemories) { - return conversationMemoryService.listMemories(userId, { limit }).catch(() => []); - } - return []; + return resolveMemoriesWithLegacyFallback({ + memoryV2, + conversationMemoryService, + userId, + sessionId, + query, + limit, + recallQuestion: isMemoryRecallQuestion(query), + }); } async function run({ @@ -348,7 +350,9 @@ export function createDirectChatService({ }, pendingMessages, ); + const recallQuestion = isMemoryRecallQuestion(messageText(userMessage)); const routedMemoryContent = + !recallQuestion && routingMemory && !routingMemory.skipped && !routingMemory.degraded ? String(routingMemory.content ?? '').trim() : ''; diff --git a/docs/examples/page-data-survey-test.md b/docs/examples/page-data-survey-test.md new file mode 100644 index 0000000..b96598e --- /dev/null +++ b/docs/examples/page-data-survey-test.md @@ -0,0 +1,75 @@ +# Page Data 问卷测试案例(TKMind 功能偏好调查) + +本地演示:john 用户工作区中的 `tkmind-survey.html` + `tkmind-survey-admin.html`。 + +## 一次性准备 + +确保 Portal 已启动(`pnpm dev`,默认 `http://localhost:8081`),然后执行: + +```bash +node scripts/setup-page-data-survey-demo.mjs +``` + +脚本会: +- **问卷页**以 `public` 发布(访客可直接提交,无需口令) +- **后台页**以 `password` 发布(口令 **88888888**,平台要求至少 8 位) + +## 测试入口 + +| 页面 | URL | +|------|-----| +| 问卷(访客提交,无需登录) | http://localhost:8081/MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/tkmind-survey.html | +| 后台(查看记录,需口令) | http://localhost:8081/MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/tkmind-survey-admin.html | + +后台登录密码:**88888888** + +## 测试步骤 + +### 1. 验证 pageId 自动注入 + +打开问卷页 → 浏览器开发者工具 → Console: + +```javascript +window.__MINDSPACE_PAGE_DATA__ +// 问卷页应返回 { pageId: "...", accessMode: "public" } +// 后台页应返回 { pageId: "...", accessMode: "password" } +``` + +### 2. 提交问卷 + +1. 打开问卷链接 +2. 逐步完成 3 道题并提交 +3. 应看到「感谢你的反馈」成功页 + +### 3. 后台查看 + +1. 打开后台链接 +2. 输入密码 `88888888` +3. 表格中应出现刚提交的记录 +4. 可点「导出 CSV」验证 + +### 4. 确认无旁路 API + +```bash +lsof -i :8899 # 应无 survey-api 进程 +``` + +数据读写应全部走同域 `/api/public/pages/:pageId/data/...`。 + +## Agent 复现流程(供对照) + +```text +load_skill → page-data-collect +private_data_execute # CREATE TABLE survey_responses ... +private_data_register_dataset +write_file # public/tkmind-survey.html + admin.html +private_data_bind_workspace_page # 问卷页 + 后台页各 bind 一次 +``` + +HTML 中只需: + +```javascript +MindSpacePageData.createClient({ apiBase: '/api' }); +``` + +无需手写 `pageId`。 diff --git a/docs/page-data-api-usage.md b/docs/page-data-api-usage.md index 10c0192..b778b01 100644 --- a/docs/page-data-api-usage.md +++ b/docs/page-data-api-usage.md @@ -4,9 +4,10 @@ ## 快速路径 -1. **Agent** 用 `private_data_execute` 建表,用 `private_data_register_dataset` 注册 dataset。 +1. **Agent** 加载 `page-data-collect` 技能(或按该技能流程执行)。 +2. **Agent** 用 `private_data_execute` 建表,用 `private_data_register_dataset` 注册 dataset。 2. **发布页面** 时在发布面板勾选「启用页面数据」,选择 dataset 与公开能力;或发布后让 Agent 调用 `private_data_set_page_policy`。 -3. **HTML 页面** 引入 `/assets/page-data-client.js`,用 `MindSpacePageData.createClient({ pageId })` 读写数据。 +3. **HTML 页面** 引入 `/assets/page-data-client.js`,用 `MindSpacePageData.createClient({ apiBase: '/api' })` 读写数据(`pageId` 可由平台在访问时自动注入)。 4. **运维** 在 MindSpace 页面详情「页面数据」面板查看日志、导出、撤销令牌、恢复软删除、关闭 dataset。 ## 访问模式与默认能力 @@ -59,8 +60,8 @@ GET /api/public/pages/:pageId/data/:dataset/stats ```html +

content

+`; + const result = injectBeforeDocumentClosingBody(html, '\n'); + assert.match(result, /\+tb\+'<\/body><\/html>'/); + assert.match(result, /