feat(wechat): add channel split with intent classifier and stub-safe page verify
Introduce wechat/ package (classifier, page-generate prompt/handler, artifact verify) and wire page.generate intents through dedicated flow. Stub HTML artifacts are never sent to users; page failures notify via customer service. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+73
-22
@@ -13,6 +13,14 @@ import { buildPublicUrl, PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
|||||||
import { buildAutoChatSkillPrefix } from './chat-skills.mjs';
|
import { buildAutoChatSkillPrefix } from './chat-skills.mjs';
|
||||||
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
|
import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs';
|
||||||
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
||||||
|
import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
|
||||||
|
import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
|
||||||
|
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
|
||||||
|
import {
|
||||||
|
buildPageGenerateAgentPrompt,
|
||||||
|
buildPagePublishFailureText,
|
||||||
|
} from './wechat/prompts/page-generate.mjs';
|
||||||
|
import { selectSendableHtmlArtifacts } from './wechat/verify/page-artifact.mjs';
|
||||||
|
|
||||||
const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
|
const DEFAULT_WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
|
||||||
const DEFAULT_WECHAT_CUSTOMER_SERVICE_URL =
|
const DEFAULT_WECHAT_CUSTOMER_SERVICE_URL =
|
||||||
@@ -339,9 +347,7 @@ function extractHtmlWriteTargets(messages = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeHtmlGenerationIntent(text) {
|
function looksLikeHtmlGenerationIntent(text) {
|
||||||
const normalized = String(text ?? '').trim().toLowerCase();
|
return isPageGenerateText(text);
|
||||||
if (!normalized) return false;
|
|
||||||
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeDocxDownloadIntent(text) {
|
function looksLikeDocxDownloadIntent(text) {
|
||||||
@@ -800,10 +806,7 @@ function downgradePrematurePublishClaims(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildHtmlPublishFailureText() {
|
export function buildHtmlPublishFailureText() {
|
||||||
return [
|
return buildPagePublishFailureText();
|
||||||
'这次页面没有按 H5 里的页面技能真正生成成功,所以我先不发不一致的简版页。',
|
|
||||||
'请直接重发一次你的页面需求,我会按和 H5 相同的 `static-page-publish` 技能链路重做。',
|
|
||||||
].join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHtmlPublishFailureMessage(message) {
|
export function isHtmlPublishFailureMessage(message) {
|
||||||
@@ -906,9 +909,7 @@ function resolveHtmlPublishArtifacts({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectHtmlPublishArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
|
function selectHtmlPublishArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
|
||||||
const candidates = verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts;
|
return selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
||||||
const sendable = nonStubHtmlArtifacts(candidates);
|
|
||||||
return sendable.length > 0 ? sendable : candidates;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStubPublicHtmlArtifact(artifact) {
|
function isStubPublicHtmlArtifact(artifact) {
|
||||||
@@ -970,11 +971,7 @@ function isConnectivityTest(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isTopicResetIntent(text) {
|
function isTopicResetIntent(text) {
|
||||||
const normalized = String(text ?? '').trim();
|
return isTopicResetText(text);
|
||||||
return (
|
|
||||||
/^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/i.test(normalized) ||
|
|
||||||
/忽略.*之前|不要管.*之前|别管.*之前/.test(normalized)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRecoverableWechatAgentSessionError(message) {
|
export function isRecoverableWechatAgentSessionError(message) {
|
||||||
@@ -1943,9 +1940,10 @@ export function createWechatMpService({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const runIntentMessage = async ({ inbound, intent, user }) => {
|
const runIntentMessage = async ({ inbound, intent, user }) => {
|
||||||
|
const wechatIntent = classifyWechatIntent(intent);
|
||||||
const resetCandidate =
|
const resetCandidate =
|
||||||
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
||||||
const forceNew = isTopicResetIntent(resetCandidate);
|
const forceNew = wechatIntent.kind === 'session.reset' || isTopicResetIntent(resetCandidate);
|
||||||
let route = await ensureWechatAgentSession({
|
let route = await ensureWechatAgentSession({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
openid: inbound.fromUserName,
|
openid: inbound.fromUserName,
|
||||||
@@ -1993,11 +1991,15 @@ export function createWechatMpService({
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const requestStartedAt = Date.now();
|
const requestStartedAt = Date.now();
|
||||||
|
const agentPrompt =
|
||||||
|
wechatIntent.kind === 'page.generate'
|
||||||
|
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
|
||||||
|
: buildWechatAgentPrompt(intent);
|
||||||
const reply = await executeSessionReply(
|
const reply = await executeSessionReply(
|
||||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||||
sessionId,
|
sessionId,
|
||||||
requestId,
|
requestId,
|
||||||
buildWechatAgentPrompt(intent),
|
agentPrompt,
|
||||||
buildIntentMetadata(intent),
|
buildIntentMetadata(intent),
|
||||||
);
|
);
|
||||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||||
@@ -2030,7 +2032,32 @@ export function createWechatMpService({
|
|||||||
hasValidLinkInReply,
|
hasValidLinkInReply,
|
||||||
});
|
});
|
||||||
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
||||||
if (looksLikeHtmlGenerationIntent(intent?.agentText)) {
|
let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
||||||
|
|
||||||
|
if (wechatIntent.kind === 'page.generate') {
|
||||||
|
const pageOutcome = resolvePageGenerateOutcome({
|
||||||
|
reply,
|
||||||
|
confirmedArtifacts,
|
||||||
|
verifiedArtifacts,
|
||||||
|
suspiciousPublishClaim,
|
||||||
|
bareCompletionReply,
|
||||||
|
htmlGenerationNeedsRetry,
|
||||||
|
replyHasPublicLinks,
|
||||||
|
});
|
||||||
|
if (pageOutcome.action === 'session_retry') {
|
||||||
|
throw new Error('stale_session_poisoned_completion');
|
||||||
|
}
|
||||||
|
if (pageOutcome.action === 'fail') {
|
||||||
|
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
|
||||||
|
try {
|
||||||
|
await sendCustomerServiceText(inbound.fromUserName, text, user);
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.error?.('WeChat MP page generate failure notice failed:', sendErr);
|
||||||
|
}
|
||||||
|
throw markWechatUserNotified(new Error(text));
|
||||||
|
}
|
||||||
|
publishArtifacts = pageOutcome.artifacts ?? publishArtifacts;
|
||||||
|
} else if (looksLikeHtmlGenerationIntent(intent?.agentText)) {
|
||||||
if (
|
if (
|
||||||
suspiciousPublishClaim ||
|
suspiciousPublishClaim ||
|
||||||
bareCompletionReply ||
|
bareCompletionReply ||
|
||||||
@@ -2053,7 +2080,6 @@ export function createWechatMpService({
|
|||||||
if (reply.tokenState) {
|
if (reply.tokenState) {
|
||||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
||||||
}
|
}
|
||||||
const publishArtifacts = selectHtmlPublishArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
|
||||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||||
workingDir,
|
workingDir,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
@@ -2080,11 +2106,15 @@ export function createWechatMpService({
|
|||||||
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
||||||
const retryId = crypto.randomUUID();
|
const retryId = crypto.randomUUID();
|
||||||
const retryStartedAt = Date.now();
|
const retryStartedAt = Date.now();
|
||||||
|
const retryPrompt =
|
||||||
|
wechatIntent.kind === 'page.generate'
|
||||||
|
? buildPageGenerateAgentPrompt(intent, { wantsDocx: wechatIntent.wantsDocx })
|
||||||
|
: buildWechatAgentPrompt(intent);
|
||||||
const reply = await executeSessionReply(
|
const reply = await executeSessionReply(
|
||||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||||
sessionId,
|
sessionId,
|
||||||
retryId,
|
retryId,
|
||||||
buildWechatAgentPrompt(intent),
|
retryPrompt,
|
||||||
buildIntentMetadata(intent),
|
buildIntentMetadata(intent),
|
||||||
);
|
);
|
||||||
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||||
@@ -2117,7 +2147,29 @@ export function createWechatMpService({
|
|||||||
hasValidLinkInReply,
|
hasValidLinkInReply,
|
||||||
});
|
});
|
||||||
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent);
|
||||||
if (looksLikeHtmlGenerationIntent(intent?.agentText)) {
|
let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
||||||
|
|
||||||
|
if (wechatIntent.kind === 'page.generate') {
|
||||||
|
const pageOutcome = resolvePageGenerateOutcome({
|
||||||
|
reply,
|
||||||
|
confirmedArtifacts,
|
||||||
|
verifiedArtifacts,
|
||||||
|
suspiciousPublishClaim,
|
||||||
|
bareCompletionReply,
|
||||||
|
htmlGenerationNeedsRetry,
|
||||||
|
replyHasPublicLinks,
|
||||||
|
});
|
||||||
|
if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') {
|
||||||
|
const text = pageOutcome.failureText ?? buildPagePublishFailureText();
|
||||||
|
try {
|
||||||
|
await sendCustomerServiceText(inbound.fromUserName, text, user);
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.error?.('WeChat MP page generate retry failure notice failed:', sendErr);
|
||||||
|
}
|
||||||
|
throw markWechatUserNotified(new Error(text));
|
||||||
|
}
|
||||||
|
publishArtifacts = pageOutcome.artifacts ?? publishArtifacts;
|
||||||
|
} else if (looksLikeHtmlGenerationIntent(intent?.agentText)) {
|
||||||
if (
|
if (
|
||||||
suspiciousPublishClaim ||
|
suspiciousPublishClaim ||
|
||||||
bareCompletionReply ||
|
bareCompletionReply ||
|
||||||
@@ -2140,7 +2192,6 @@ export function createWechatMpService({
|
|||||||
if (reply.tokenState) {
|
if (reply.tokenState) {
|
||||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
||||||
}
|
}
|
||||||
const publishArtifacts = selectHtmlPublishArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
|
||||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||||
workingDir,
|
workingDir,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
|||||||
+2
-2
@@ -329,7 +329,7 @@ test('wechat mp service rejects blocked publish claims that did not use the H5 p
|
|||||||
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||||
const payload = JSON.parse(sendCall[2]);
|
const payload = JSON.parse(sendCall[2]);
|
||||||
assert.doesNotMatch(payload.text.content, /成功发布|主题页面已发布/);
|
assert.doesNotMatch(payload.text.content, /成功发布|主题页面已发布/);
|
||||||
assert.match(payload.text.content, /没有按 H5 里的页面技能真正生成成功/);
|
assert.match(payload.text.content, /服务号页面技能|没有按 H5 里的页面技能/);
|
||||||
assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/);
|
assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2576,7 +2576,7 @@ test('wechat mp service does not trust unrelated recent html artifacts when repl
|
|||||||
const payload = JSON.parse(sendCall[2]);
|
const payload = JSON.parse(sendCall[2]);
|
||||||
assert.doesNotMatch(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello\.html/);
|
assert.doesNotMatch(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello\.html/);
|
||||||
assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/);
|
assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/);
|
||||||
assert.match(payload.text.content, /没有按 H5 里的页面技能真正生成成功|页面生成未完成/);
|
assert.match(payload.text.content, /服务号页面技能|没有按 H5 里的页面技能|页面生成未完成/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wechat mp service forwards H5 agent text when page generation produced no file or fake link', async () => {
|
test('wechat mp service forwards H5 agent text when page generation produced no file or fake link', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { buildPagePublishFailureText } from '../prompts/page-generate.mjs';
|
||||||
|
import { selectSendableHtmlArtifacts, verifyPageArtifactContent } from '../verify/page-artifact.mjs';
|
||||||
|
|
||||||
|
export function evaluatePageGenerateSendableArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
|
||||||
|
const sendable = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
||||||
|
const verified = sendable.filter((artifact) => verifyPageArtifactContent(artifact).ok);
|
||||||
|
return verified.length > 0 ? verified : sendable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a page.generate reply may be delivered to the user.
|
||||||
|
* Returns { action: 'send'|'fail'|'session_retry', failureText?, reason? }
|
||||||
|
*/
|
||||||
|
export function resolvePageGenerateOutcome({
|
||||||
|
reply,
|
||||||
|
confirmedArtifacts = [],
|
||||||
|
verifiedArtifacts = [],
|
||||||
|
suspiciousPublishClaim = false,
|
||||||
|
bareCompletionReply = false,
|
||||||
|
htmlGenerationNeedsRetry = false,
|
||||||
|
replyHasPublicLinks = false,
|
||||||
|
}) {
|
||||||
|
const sendable = evaluatePageGenerateSendableArtifacts({ verifiedArtifacts, confirmedArtifacts });
|
||||||
|
|
||||||
|
if (sendable.length > 0) {
|
||||||
|
return { action: 'send', artifacts: sendable };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
suspiciousPublishClaim ||
|
||||||
|
bareCompletionReply ||
|
||||||
|
(htmlGenerationNeedsRetry && replyHasPublicLinks && confirmedArtifacts.length === 0)
|
||||||
|
) {
|
||||||
|
return { action: 'session_retry', reason: 'poisoned_or_fake_claim' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (htmlGenerationNeedsRetry || suspiciousPublishClaim) {
|
||||||
|
return {
|
||||||
|
action: 'fail',
|
||||||
|
failureText: buildPagePublishFailureText(),
|
||||||
|
reason: 'skill_or_stub',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String(reply?.text ?? '').trim() && sendable.length === 0) {
|
||||||
|
return {
|
||||||
|
action: 'fail',
|
||||||
|
failureText: buildPagePublishFailureText(),
|
||||||
|
reason: 'empty_reply',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: 'send', artifacts: sendable };
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
CONNECTIVITY_TEST_PATTERN,
|
||||||
|
GREETING_PATTERN,
|
||||||
|
STATUS_PROBE_PATTERN,
|
||||||
|
isPageGenerateText,
|
||||||
|
isTopicResetText,
|
||||||
|
wantsDocxDownload,
|
||||||
|
} from './patterns.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} WechatPageGenerateIntent
|
||||||
|
* @property {'page.generate'} kind
|
||||||
|
* @property {string} topic
|
||||||
|
* @property {boolean} wantsDocx
|
||||||
|
*
|
||||||
|
* @typedef {object} WechatSessionResetIntent
|
||||||
|
* @property {'session.reset'} kind
|
||||||
|
*
|
||||||
|
* @typedef {object} WechatStatusProbeIntent
|
||||||
|
* @property {'status.probe'} kind
|
||||||
|
*
|
||||||
|
* @typedef {object} WechatGreetingIntent
|
||||||
|
* @property {'greeting'} kind
|
||||||
|
*
|
||||||
|
* @typedef {object} WechatConnectivityTestIntent
|
||||||
|
* @property {'connectivity.test'} kind
|
||||||
|
*
|
||||||
|
* @typedef {object} WechatGeneralChatIntent
|
||||||
|
* @property {'chat.general'} kind
|
||||||
|
* @property {string} text
|
||||||
|
*
|
||||||
|
* @typedef {object} WechatUnsupportedIntent
|
||||||
|
* @property {'unsupported'} kind
|
||||||
|
* @property {string} msgType
|
||||||
|
*
|
||||||
|
* @typedef {WechatPageGenerateIntent|WechatSessionResetIntent|WechatStatusProbeIntent|WechatGreetingIntent|WechatConnectivityTestIntent|WechatGeneralChatIntent|WechatUnsupportedIntent} WechatIntent
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a normalized WeChat inbound intent (from wechat-mp normalizeWechatInboundIntent).
|
||||||
|
* @param {{ msgType?: string, agentText?: string, displayText?: string }} intent
|
||||||
|
* @returns {WechatIntent}
|
||||||
|
*/
|
||||||
|
export function classifyWechatIntent(intent) {
|
||||||
|
const msgType = String(intent?.msgType ?? 'text').toLowerCase();
|
||||||
|
const text = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||||||
|
|
||||||
|
if (msgType === 'text' || msgType === 'voice') {
|
||||||
|
if (isTopicResetText(text)) return { kind: 'session.reset' };
|
||||||
|
if (STATUS_PROBE_PATTERN.test(text)) return { kind: 'status.probe' };
|
||||||
|
if (GREETING_PATTERN.test(text.replace(/[!!。.\s]+$/g, ''))) return { kind: 'greeting' };
|
||||||
|
if (CONNECTIVITY_TEST_PATTERN.test(text.replace(/[!!。.\s]+$/g, ''))) {
|
||||||
|
return { kind: 'connectivity.test' };
|
||||||
|
}
|
||||||
|
if (isPageGenerateText(text)) {
|
||||||
|
return {
|
||||||
|
kind: 'page.generate',
|
||||||
|
topic: text,
|
||||||
|
wantsDocx: wantsDocxDownload(text),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { kind: 'chat.general', text };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgType === 'event') {
|
||||||
|
return { kind: 'unsupported', msgType: 'event' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'chat.general', text: text || `[${msgType}]` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPageGenerateIntent(intent) {
|
||||||
|
return classifyWechatIntent(intent).kind === 'page.generate';
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** WeChat channel intent patterns — not shared with H5 chat-skills routing. */
|
||||||
|
|
||||||
|
export const PAGE_GENERATE_PATTERN =
|
||||||
|
/(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/iu;
|
||||||
|
|
||||||
|
export const DOCX_DOWNLOAD_PATTERN =
|
||||||
|
/(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu;
|
||||||
|
|
||||||
|
export const TOPIC_RESET_PATTERN =
|
||||||
|
/^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/iu;
|
||||||
|
|
||||||
|
export const TOPIC_RESET_LOOSE_PATTERN = /忽略.*之前|不要管.*之前|别管.*之前/u;
|
||||||
|
|
||||||
|
export const STATUS_PROBE_PATTERN = /^[??]+$/u;
|
||||||
|
|
||||||
|
export const GREETING_PATTERN =
|
||||||
|
/^(你好|您好|在吗|在不在|嗨|hi|hello|hey)[!!。.\s]*$/iu;
|
||||||
|
|
||||||
|
export const CONNECTIVITY_TEST_PATTERN = /^(测试\s*\d*|test\s*\d*)[!!。.\s]*$/iu;
|
||||||
|
|
||||||
|
export function isPageGenerateText(text) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return PAGE_GENERATE_PATTERN.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTopicResetText(text) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return TOPIC_RESET_PATTERN.test(normalized) || TOPIC_RESET_LOOSE_PATTERN.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wantsDocxDownload(text) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return DOCX_DOWNLOAD_PATTERN.test(normalized);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service-account-only page generation prompt. Does not use H5 buildAutoChatSkillPrefix.
|
||||||
|
*/
|
||||||
|
export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {}) {
|
||||||
|
const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||||||
|
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
||||||
|
const docxBlock = wantsDocx
|
||||||
|
? [
|
||||||
|
'【Word 下载】用户要求页面内可下载 Word/docx。',
|
||||||
|
'先 `load_skill` → `docx-generate` 生成 `public/*.docx`,再写 HTML 并链接同目录文档。',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'【微信服务号 · 页面生成任务】',
|
||||||
|
'这是服务号专用页面生成,不是普通聊天。必须按步骤完成,未完成前禁止告诉用户“已生成/已发布”。',
|
||||||
|
'',
|
||||||
|
docxBlock,
|
||||||
|
'步骤(必须全部完成):',
|
||||||
|
'1. 调用 `load_skill`,参数 name=`static-page-publish`。',
|
||||||
|
'2. 阅读技能说明后,用 sandbox-fs 的 `write_file` 或 `edit_file` 写入 `public/*.html`。',
|
||||||
|
'3. 禁止 shell/cat/heredoc/cp 写 HTML(不会出现在公网 MindSpace)。',
|
||||||
|
'4. 写完后确认目标文件已落盘,且内容是用户要的完整页面(不是占位 stub)。',
|
||||||
|
'5. 回复里只给一个正式域名链接;没有落盘就不要发链接。',
|
||||||
|
'',
|
||||||
|
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
|
||||||
|
`用户需求:${topic}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPagePublishFailureText() {
|
||||||
|
return [
|
||||||
|
'这次页面没有按服务号页面技能真正生成成功,所以我先不发占位链接。',
|
||||||
|
'请直接重发一次完整页面需求,我会重新按 static-page-publish 技能链路生成。',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面'];
|
||||||
|
|
||||||
|
export function isStubPublicHtmlContent(content) {
|
||||||
|
const value = String(content ?? '');
|
||||||
|
return STUB_MARKERS.some((marker) => value.includes(marker));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function artifactFileExists(artifact) {
|
||||||
|
const localPath = String(artifact?.localPath ?? '').trim();
|
||||||
|
if (!localPath) return false;
|
||||||
|
try {
|
||||||
|
return fs.existsSync(localPath) && fs.statSync(localPath).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStubPublicHtmlArtifact(artifact) {
|
||||||
|
const localPath = String(artifact?.localPath ?? '').trim();
|
||||||
|
if (!localPath) return false;
|
||||||
|
try {
|
||||||
|
return isStubPublicHtmlContent(fs.readFileSync(localPath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real HTML artifacts only — never fall back to stub placeholders. */
|
||||||
|
export function filterSendableHtmlArtifacts(artifacts = []) {
|
||||||
|
return artifacts.filter((artifact) => artifactFileExists(artifact) && !isStubPublicHtmlArtifact(artifact));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectSendableHtmlArtifacts({ verifiedArtifacts = [], confirmedArtifacts = [] } = {}) {
|
||||||
|
const candidates = verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts;
|
||||||
|
return filterSendableHtmlArtifacts(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPageArtifactContent(artifact, { minBytes = 512 } = {}) {
|
||||||
|
if (!artifactFileExists(artifact)) {
|
||||||
|
return { ok: false, reason: 'missing_file' };
|
||||||
|
}
|
||||||
|
if (isStubPublicHtmlArtifact(artifact)) {
|
||||||
|
return { ok: false, reason: 'stub_placeholder' };
|
||||||
|
}
|
||||||
|
const size = fs.statSync(artifact.localPath).size;
|
||||||
|
if (size < minBytes) {
|
||||||
|
return { ok: false, reason: 'too_small' };
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(artifact.localPath, 'utf8');
|
||||||
|
if (!/<(?:html|body|main|article)\b/i.test(content)) {
|
||||||
|
return { ok: false, reason: 'not_html_document' };
|
||||||
|
}
|
||||||
|
return { ok: true, reason: null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { classifyWechatIntent, isPageGenerateIntent } from './intent/classifier.mjs';
|
||||||
|
import {
|
||||||
|
filterSendableHtmlArtifacts,
|
||||||
|
isStubPublicHtmlContent,
|
||||||
|
selectSendableHtmlArtifacts,
|
||||||
|
verifyPageArtifactContent,
|
||||||
|
} from './verify/page-artifact.mjs';
|
||||||
|
import { resolvePageGenerateOutcome } from './handlers/page-generate.mjs';
|
||||||
|
|
||||||
|
test('classifyWechatIntent detects page.generate', () => {
|
||||||
|
const intent = classifyWechatIntent({
|
||||||
|
msgType: 'text',
|
||||||
|
agentText: '帮我生成一个唐诗页面,放一首诗',
|
||||||
|
});
|
||||||
|
assert.equal(intent.kind, 'page.generate');
|
||||||
|
assert.match(intent.topic, /唐诗页面/);
|
||||||
|
assert.equal(isPageGenerateIntent({ msgType: 'text', agentText: intent.topic }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyWechatIntent detects session.reset', () => {
|
||||||
|
assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换话题' }).kind, 'session.reset');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectSendableHtmlArtifacts never returns stub html', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-stub-'));
|
||||||
|
const stubPath = path.join(dir, 'tang-poem.html');
|
||||||
|
fs.writeFileSync(
|
||||||
|
stubPath,
|
||||||
|
'<html>服务号自动补出简版页面</html>',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const realPath = path.join(dir, 'real.html');
|
||||||
|
fs.writeFileSync(
|
||||||
|
realPath,
|
||||||
|
`<!doctype html><html><body><main>${'x'.repeat(600)}</main></body></html>`,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const artifacts = [
|
||||||
|
{ localPath: stubPath, relativePath: 'public/tang-poem.html', url: 'https://example/tang-poem.html' },
|
||||||
|
{ localPath: realPath, relativePath: 'public/real.html', url: 'https://example/real.html' },
|
||||||
|
];
|
||||||
|
const sendable = selectSendableHtmlArtifacts({ confirmedArtifacts: artifacts });
|
||||||
|
assert.equal(sendable.length, 1);
|
||||||
|
assert.match(sendable[0].relativePath, /real\.html/);
|
||||||
|
assert.equal(isStubPublicHtmlContent('<html>服务号自动补出简版页面</html>'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvePageGenerateOutcome fails when only stub artifacts exist', () => {
|
||||||
|
const outcome = resolvePageGenerateOutcome({
|
||||||
|
reply: { text: '页面已生成 https://m.tkmind.cn/MindSpace/u/public/tang-poem.html' },
|
||||||
|
confirmedArtifacts: [
|
||||||
|
{
|
||||||
|
localPath: '/tmp/stub.html',
|
||||||
|
relativePath: 'public/tang-poem.html',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
htmlGenerationNeedsRetry: true,
|
||||||
|
replyHasPublicLinks: true,
|
||||||
|
});
|
||||||
|
assert.equal(outcome.action, 'fail');
|
||||||
|
assert.match(outcome.failureText, /服务号页面技能/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyPageArtifactContent rejects small stub files', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-verify-'));
|
||||||
|
const file = path.join(dir, 'page.html');
|
||||||
|
fs.writeFileSync(file, '<html>服务号兜底</html>', 'utf8');
|
||||||
|
const artifact = { localPath: file, relativePath: 'public/page.html' };
|
||||||
|
assert.equal(verifyPageArtifactContent(artifact).ok, false);
|
||||||
|
assert.equal(filterSendableHtmlArtifacts([artifact]).length, 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user