V1.2: 添加问题过滤系统、AI Prompt优化和请求稳态层
新增功能: - 问题过滤与质量评分系统 - 改写建议弹窗交互 - AI Prompt 三层结构优化 - AI 请求稳态管理器(请求锁、超时控制、自动重试、JSON保护) - 动态加载状态提示 优化改进: - 修复正则表达式匹配问题 - 修复弹窗按钮文字长度限制 - 优化改写建议的关联性和情感共鸣 - 增强 AI 解读的问题相关性和稳定性
This commit is contained in:
parent
71a73908fa
commit
27b5c0a3ad
|
|
@ -6,6 +6,8 @@ const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/chat/completions';
|
||||||
const { minorArcana } = require('../../data/tarot/index');
|
const { minorArcana } = require('../../data/tarot/index');
|
||||||
const { getQuestionType } = require('../../utils/questionType');
|
const { getQuestionType } = require('../../utils/questionType');
|
||||||
const { generateSpreadStory } = require('../../utils/spreadStory');
|
const { generateSpreadStory } = require('../../utils/spreadStory');
|
||||||
|
const { validateQuestion, scoreQuestion } = require('../../utils/questionFilter');
|
||||||
|
const { safeAIRequest } = require('../../utils/aiRequestManager');
|
||||||
|
|
||||||
// --- Prompt 模板定义 ---
|
// --- Prompt 模板定义 ---
|
||||||
|
|
||||||
|
|
@ -52,25 +54,100 @@ const TAROT_PROMPT_NIGHT = `你是一位安静、克制、不制造依赖的塔
|
||||||
【输出要求】
|
【输出要求】
|
||||||
- 只输出解读文本`;
|
- 只输出解读文本`;
|
||||||
|
|
||||||
// 3. 增强版模板:结构化深度解读
|
|
||||||
const TAROT_PROMPT_ENHANCED = `你是一位专业、温和且具有洞察力的塔罗解读者。
|
|
||||||
你的任务是根据选定的牌阵,为用户提供有深度、有层次且具有陪伴感的解读。
|
|
||||||
|
|
||||||
【解读边界】
|
// 3. 增强版模板:解读稳定器 + 高关联性结构化解读
|
||||||
- 不预测具体事件、不给绝对结论、不使用恐吓性语言
|
const TAROT_PROMPT_ENHANCED = `📏 解读稳定规则(高优先级执行)
|
||||||
- 保持温和的引导口气,像是在进行一场心灵对话
|
|
||||||
|
|
||||||
【解读结构】
|
你必须保持解读风格稳定、具体且一致,不允许出现质量波动。
|
||||||
请严格按照以下 JSON 格式输出解读内容,不要包含任何其他说明性文字:
|
|
||||||
|
【一、解读长度控制】
|
||||||
|
• 整体长度:400~700 字(不少于 300 字,不超过 900 字)
|
||||||
|
• 各模块建议:
|
||||||
|
- 问题回应:2~3句
|
||||||
|
- 牌面分析:每张牌2句以内
|
||||||
|
- 当前状态:2~3句
|
||||||
|
- 发展趋势:2~3句
|
||||||
|
- 行动建议:3~5条短句
|
||||||
|
• 禁止:超长段落、大段心理学科普、冗长比喻
|
||||||
|
|
||||||
|
【二、结构稳定规则(必须严格执行)】
|
||||||
|
输出必须包含且仅包含以下5个部分,不允许新增其他模块:
|
||||||
|
1. 问题回应
|
||||||
|
2. 牌面分析
|
||||||
|
3. 当前状态
|
||||||
|
4. 发展趋势
|
||||||
|
5. 行动建议
|
||||||
|
|
||||||
|
【三、解读深度控制】
|
||||||
|
每段必须包含至少一个:情境描述、心理状态、行为倾向、关系互动
|
||||||
|
禁止:单纯解释牌意、抽象空话、能量类泛词堆叠
|
||||||
|
|
||||||
|
【四、情绪语气稳定器】
|
||||||
|
• 必须:温和、理解用户、提供支持
|
||||||
|
• 避免:过度肯定(一定会)、绝对否定(绝不会)、命令式建议、恐吓式预测
|
||||||
|
• 推荐句式:"你当前可能正在经历…" "这可能让你感到…" "可以尝试…"
|
||||||
|
|
||||||
|
【五、百科化防护规则(非常重要)】
|
||||||
|
禁止出现:"通常这张牌代表…" "根据牌义…" "在传统塔罗中…"
|
||||||
|
如果出现类似内容,必须立即转化为与用户问题相关的情境说明。
|
||||||
|
|
||||||
|
【六、参考牌义权重限制】
|
||||||
|
信息优先级:用户问题 > 提问领域 > 抽到的牌 > 参考牌义
|
||||||
|
参考牌义只能用于理解,不允许复述或改写复述。
|
||||||
|
|
||||||
|
【七、质量自检(生成前内部执行)】
|
||||||
|
在输出前检查:
|
||||||
|
✓ 开头是否回应问题
|
||||||
|
✓ 是否逐张牌关联问题
|
||||||
|
✓ 是否包含可执行建议
|
||||||
|
✓ 是否出现百科语句
|
||||||
|
✓ 是否结构完整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🎯 角色设定
|
||||||
|
|
||||||
|
你是一位专业、温和、具有洞察力的塔罗解读师。
|
||||||
|
你的解读必须围绕用户提出的问题展开,而不是输出通用牌义。
|
||||||
|
|
||||||
|
⚠️ 本次解读优先级:
|
||||||
|
用户问题 > 提问领域 > 抽到的牌 > 参考牌义
|
||||||
|
|
||||||
|
🧭 解读核心要求(非常重要)
|
||||||
|
|
||||||
|
1️⃣ 解读必须紧密围绕「用户问题」展开
|
||||||
|
2️⃣ 开头第一句话必须直接回应用户的问题情境
|
||||||
|
3️⃣ 每一张牌都要说明它与该问题的具体关系
|
||||||
|
4️⃣ 禁止输出通用百科式牌义解释
|
||||||
|
5️⃣ 必须结合提问领域进行情境化分析
|
||||||
|
6️⃣ 解读重点放在状态、趋势与建议,而非绝对预测
|
||||||
|
|
||||||
|
🎨 语言风格要求
|
||||||
|
|
||||||
|
• 温和、具体、有针对性
|
||||||
|
• 使用"你当前的情况可能是…"
|
||||||
|
• 避免绝对判断和命令式语气
|
||||||
|
• 不要出现:"根据牌义来说…" "通常这张牌代表…"
|
||||||
|
|
||||||
|
🚫 禁止行为
|
||||||
|
|
||||||
|
• 不要复述参考牌义原文
|
||||||
|
• 不要脱离用户问题进行泛泛而谈
|
||||||
|
• 不要写百科解释
|
||||||
|
• 不要生成医疗、法律、金融建议
|
||||||
|
|
||||||
|
📤 输出格式
|
||||||
|
|
||||||
|
请严格按照以下 JSON 格式输出,不要包含任何其他说明性文字:
|
||||||
{
|
{
|
||||||
"theme": "用一句话概括本次解读的核心主题",
|
"theme": "直接回应用户问题的核心主题(必须提及问题关键词)",
|
||||||
"status": "描述用户当下的情绪或处境状态",
|
"status": "用户在这个问题上的当前状态",
|
||||||
"influence": "分析当前局面背后的潜在影响因素(内在或外在)",
|
"influence": "影响这个问题的潜在因素",
|
||||||
"advice": "给出具体、温和且具有操作性的行动建议",
|
"advice": "温和、可执行的个人成长建议",
|
||||||
"positions": [
|
"positions": [
|
||||||
{
|
{
|
||||||
"posName": "位置名称",
|
"posName": "位置名称",
|
||||||
"posMeaning": "由你结合牌面对该位置的详细解读(约 60-100 字)"
|
"posMeaning": "解释这张牌如何影响该问题情境(60-100字,必须联系问题)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`;
|
}`;
|
||||||
|
|
@ -183,6 +260,7 @@ Page({
|
||||||
selectedSpread: null,
|
selectedSpread: null,
|
||||||
question: '', // 用户输入的问题
|
question: '', // 用户输入的问题
|
||||||
questionType: '综合问题', // 问题类型
|
questionType: '综合问题', // 问题类型
|
||||||
|
questionQuality: null, // 问题质量评分
|
||||||
drawnCardIndices: [], // 用户选中的牌索引
|
drawnCardIndices: [], // 用户选中的牌索引
|
||||||
drawnCards: [], // 实际抽到的牌对象
|
drawnCards: [], // 实际抽到的牌对象
|
||||||
revealedCount: 0, // 已翻开的数量
|
revealedCount: 0, // 已翻开的数量
|
||||||
|
|
@ -196,10 +274,11 @@ Page({
|
||||||
infoAnimation: {},
|
infoAnimation: {},
|
||||||
guideAnimation: {},
|
guideAnimation: {},
|
||||||
|
|
||||||
// AI 相关
|
// AI 解读相关
|
||||||
|
aiResult: null,
|
||||||
isAiLoading: false,
|
isAiLoading: false,
|
||||||
aiExplanation: '',
|
aiLoadingText: '正在抽取牌面…', // 动态加载提示
|
||||||
spreadStory: '', // 综合故事线
|
spreadStory: '',
|
||||||
|
|
||||||
// 3. 前缀文案列表(随机抽取)
|
// 3. 前缀文案列表(随机抽取)
|
||||||
prefixList: [
|
prefixList: [
|
||||||
|
|
@ -422,8 +501,65 @@ Page({
|
||||||
// --- 1.5 确认问题并开始洗牌 ---
|
// --- 1.5 确认问题并开始洗牌 ---
|
||||||
confirmQuestion: function () {
|
confirmQuestion: function () {
|
||||||
const question = this.data.question.trim();
|
const question = this.data.question.trim();
|
||||||
const questionType = getQuestionType(question);
|
console.log('confirmQuestion 被调用,问题:', question);
|
||||||
|
|
||||||
|
// 新增:过滤验证
|
||||||
|
const filterResult = validateQuestion(question);
|
||||||
|
console.log('过滤结果:', filterResult);
|
||||||
|
|
||||||
|
if (filterResult.status === 'reject') {
|
||||||
|
console.log('触发拒绝弹窗');
|
||||||
|
// 显示拒绝弹窗
|
||||||
|
wx.showModal({
|
||||||
|
title: '温馨提示',
|
||||||
|
content: filterResult.reason,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '我知道了'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterResult.status === 'rewrite') {
|
||||||
|
console.log('触发改写建议弹窗');
|
||||||
|
// 显示改写建议弹窗 - 简化版本
|
||||||
|
const firstSuggestion = filterResult.suggestions[0];
|
||||||
|
const modalContent = `${filterResult.reason}\n\n${firstSuggestion}`;
|
||||||
|
console.log('弹窗内容:', modalContent);
|
||||||
|
|
||||||
|
wx.showModal({
|
||||||
|
title: '提问建议',
|
||||||
|
content: modalContent,
|
||||||
|
confirmText: '使用建议',
|
||||||
|
cancelText: '继续提问',
|
||||||
|
success: (res) => {
|
||||||
|
console.log('弹窗回调:', res);
|
||||||
|
if (res.confirm) {
|
||||||
|
// 使用建议问题
|
||||||
|
this.setData({
|
||||||
|
question: firstSuggestion,
|
||||||
|
questionQuality: scoreQuestion(firstSuggestion)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 无论选择哪个,都继续流程
|
||||||
|
this.proceedToShuffle();
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('弹窗显示失败:', err);
|
||||||
|
// 即使弹窗失败,也继续流程
|
||||||
|
this.proceedToShuffle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('通过验证,继续流程');
|
||||||
|
// pass 状态:直接继续
|
||||||
|
this.proceedToShuffle();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 抽离洗牌逻辑
|
||||||
|
proceedToShuffle: function () {
|
||||||
|
const questionType = getQuestionType(this.data.question);
|
||||||
this.setData({
|
this.setData({
|
||||||
questionType: questionType,
|
questionType: questionType,
|
||||||
state: 'shuffling'
|
state: 'shuffling'
|
||||||
|
|
@ -437,8 +573,11 @@ Page({
|
||||||
|
|
||||||
// 输入问题
|
// 输入问题
|
||||||
onQuestionInput: function (e) {
|
onQuestionInput: function (e) {
|
||||||
|
const question = e.detail.value;
|
||||||
|
const quality = scoreQuestion(question);
|
||||||
this.setData({
|
this.setData({
|
||||||
question: e.detail.value
|
question: question,
|
||||||
|
questionQuality: quality
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -508,13 +647,14 @@ Page({
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- 获取 AI 解读主逻辑 ---
|
// --- 获取 AI 解读主逻辑 ---
|
||||||
fetchAiInterpretation: function () {
|
fetchAiInterpretation: async function () {
|
||||||
const { drawnCards, selectedSpread } = this.data;
|
const { drawnCards, selectedSpread, question, questionType } = this.data;
|
||||||
if (!drawnCards.length) return;
|
if (!drawnCards.length) return;
|
||||||
|
|
||||||
this.setData({
|
this.setData({
|
||||||
isAiLoading: true,
|
isAiLoading: true,
|
||||||
aiResult: null
|
aiResult: null,
|
||||||
|
aiLoadingText: '正在抽取牌面…'
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. 构建卡牌信息
|
// 1. 构建卡牌信息
|
||||||
|
|
@ -522,13 +662,39 @@ Page({
|
||||||
return `位置[${selectedSpread.positions[index]}]:${card.name} (${card.isReversed ? '逆位' : '正位'}),关键词:${card.keyword || (card.keywords ? card.keywords.join(',') : '')}`;
|
return `位置[${selectedSpread.positions[index]}]:${card.name} (${card.isReversed ? '逆位' : '正位'}),关键词:${card.keyword || (card.keywords ? card.keywords.join(',') : '')}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
const userPrompt = `牌阵:${selectedSpread.name}\n${cardDetails}`;
|
// 2. 构建参考牌义(供 AI 理解用)
|
||||||
|
const cardMeanings = drawnCards.map((card, index) => {
|
||||||
|
const meaning = card.isReversed ? (card.reversedMeaning || card.description) : (card.meaning || card.description);
|
||||||
|
return `${card.name} (${card.isReversed ? '逆位' : '正位'}):${meaning}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
// 3. 构建完整的用户提示
|
||||||
|
const userPrompt = `📥 输入信息
|
||||||
|
|
||||||
|
用户问题:
|
||||||
|
${question || '未指定具体问题'}
|
||||||
|
|
||||||
|
提问领域:
|
||||||
|
${questionType}
|
||||||
|
|
||||||
|
牌阵:
|
||||||
|
${selectedSpread.name}
|
||||||
|
|
||||||
|
抽到的牌:
|
||||||
|
${cardDetails}
|
||||||
|
|
||||||
|
参考牌义(内部理解用):
|
||||||
|
${cardMeanings}
|
||||||
|
|
||||||
|
⚠️ 参考牌义仅用于理解,不允许逐字复述。请严格围绕用户的问题进行解读。`;
|
||||||
|
|
||||||
console.log("正在获取深度解读...");
|
console.log("正在获取深度解读...");
|
||||||
|
console.log("用户问题:", question);
|
||||||
|
console.log("提问领域:", questionType);
|
||||||
|
|
||||||
wx.request({
|
// 4. 使用稳态请求管理器
|
||||||
|
const result = await safeAIRequest({
|
||||||
url: DEEPSEEK_BASE_URL,
|
url: DEEPSEEK_BASE_URL,
|
||||||
method: 'POST',
|
|
||||||
header: {
|
header: {
|
||||||
'Authorization': `Bearer ${DEEPSEEK_API_KEY}`,
|
'Authorization': `Bearer ${DEEPSEEK_API_KEY}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
|
@ -542,30 +708,42 @@ Page({
|
||||||
stream: false,
|
stream: false,
|
||||||
response_format: { type: "json_object" }
|
response_format: { type: "json_object" }
|
||||||
},
|
},
|
||||||
timeout: 20000,
|
drawnCards,
|
||||||
success: (res) => {
|
spread: selectedSpread,
|
||||||
if (res.data && res.data.choices && res.data.choices[0]) {
|
onProgress: (text) => {
|
||||||
try {
|
this.setData({ aiLoadingText: text });
|
||||||
const aiResult = JSON.parse(res.data.choices[0].message.content);
|
|
||||||
const spreadStory = generateSpreadStory(drawnCards);
|
|
||||||
this.setData({
|
|
||||||
aiResult,
|
|
||||||
spreadStory: spreadStory,
|
|
||||||
isAiLoading: false
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("JSON 解析失败", e);
|
|
||||||
this.handleAiFallback();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.handleAiFallback();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: (err) => {
|
|
||||||
console.error("请求失败", err);
|
|
||||||
this.handleAiFallback();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 5. 处理结果
|
||||||
|
if (result.status === 'blocked') {
|
||||||
|
wx.showToast({
|
||||||
|
title: result.message,
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
this.setData({ isAiLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 生成整体趋势
|
||||||
|
const spreadStory = generateSpreadStory(drawnCards);
|
||||||
|
|
||||||
|
// 7. 更新数据
|
||||||
|
this.setData({
|
||||||
|
aiResult: result.data,
|
||||||
|
spreadStory: spreadStory,
|
||||||
|
isAiLoading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. 如果是 fallback,显示提示
|
||||||
|
if (result.status === 'fallback' && result.error) {
|
||||||
|
wx.showToast({
|
||||||
|
title: 'AI 解读失败,已使用备用解读',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleAiFallback: function () {
|
handleAiFallback: function () {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
></textarea>
|
></textarea>
|
||||||
<text class="char-count">{{question.length}}/200</text>
|
<text class="char-count">{{question.length}}/200</text>
|
||||||
|
|
||||||
|
<!-- 新增:质量评分显示 -->
|
||||||
|
<view class="quality-indicator" wx:if="{{questionQuality && question.length > 0}}">
|
||||||
|
<text class="quality-icon">{{questionQuality.icon}}</text>
|
||||||
|
<text class="quality-text">提问质量:{{questionQuality.level}}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<button class="start-btn" bindtap="confirmQuestion" hover-class="btn-hover">开始抽牌</button>
|
<button class="start-btn" bindtap="confirmQuestion" hover-class="btn-hover">开始抽牌</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
@ -143,7 +149,7 @@
|
||||||
<view class="interpretation-area" wx:if="{{revealedCount === selectedSpread.cardCount}}" animation="{{infoAnimation}}">
|
<view class="interpretation-area" wx:if="{{revealedCount === selectedSpread.cardCount}}" animation="{{infoAnimation}}">
|
||||||
<view class="loading-ai" wx:if="{{isAiLoading}}">
|
<view class="loading-ai" wx:if="{{isAiLoading}}">
|
||||||
<text class="loading-dot">···</text>
|
<text class="loading-dot">···</text>
|
||||||
<text>正在深度解读能量流向...</text>
|
<text>{{aiLoadingText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="structured-result" wx:else>
|
<view class="structured-result" wx:else>
|
||||||
|
|
|
||||||
|
|
@ -521,3 +521,27 @@ page {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 质量评分指示器 */
|
||||||
|
.quality-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
background: linear-gradient(135deg, rgba(139, 233, 253, 0.1), rgba(80, 250, 123, 0.1));
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 1px solid rgba(139, 233, 253, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-icon {
|
||||||
|
font-size: 40rpx;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #8be9fd;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
/**
|
||||||
|
* AI 请求稳态管理器
|
||||||
|
* 提供请求锁、超时控制、自动重试、JSON 解析保护、失败兜底
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 请求锁
|
||||||
|
let isRequesting = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全的 JSON 解析
|
||||||
|
* @param {string} text - 待解析的文本
|
||||||
|
* @returns {Object} 解析结果
|
||||||
|
*/
|
||||||
|
function safeJSONParse(text) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('AI_JSON_FALLBACK: 首次解析失败,尝试提取 JSON 代码块');
|
||||||
|
|
||||||
|
// 尝试提取 JSON 代码块(可能被包裹在 ```json ``` 中)
|
||||||
|
const jsonMatch = text.match(/```json\s*([\s\S]*?)\s*```/) || text.match(/```\s*([\s\S]*?)\s*```/);
|
||||||
|
if (jsonMatch && jsonMatch[1]) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonMatch[1]);
|
||||||
|
} catch (e2) {
|
||||||
|
console.error('AI_JSON_FALLBACK: JSON 代码块解析也失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完全失败,返回 fallback 状态
|
||||||
|
return {
|
||||||
|
status: 'fallback_text',
|
||||||
|
raw: text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成兜底解读内容
|
||||||
|
* @param {Array} drawnCards - 抽到的牌
|
||||||
|
* @param {Object} spread - 牌阵信息
|
||||||
|
* @returns {Object} 兜底解读
|
||||||
|
*/
|
||||||
|
function generateFallbackInterpretation(drawnCards, spread) {
|
||||||
|
const positions = drawnCards.map((card, index) => ({
|
||||||
|
posName: spread.positions[index],
|
||||||
|
posMeaning: `${card.name}${card.isReversed ? '(逆位)' : '(正位)'}提示你关注当前的能量变化,保持觉察。`
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme: '本次牌面提示你正处于一个需要观察和思考的阶段',
|
||||||
|
status: '你可能正在经历一些内在的调整',
|
||||||
|
influence: '当前能量正在变化之中',
|
||||||
|
advice: '给自己一点时间观察,避免过度解读当前状况,关注真实感受',
|
||||||
|
positions: positions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行单次 AI 请求
|
||||||
|
* @param {Object} config - 请求配置
|
||||||
|
* @returns {Promise} 请求结果
|
||||||
|
*/
|
||||||
|
function executeRequest(config) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { url, data, timeout = 90000 } = config;
|
||||||
|
|
||||||
|
wx.request({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
header: config.header,
|
||||||
|
data,
|
||||||
|
timeout,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.data && res.data.choices && res.data.choices[0]) {
|
||||||
|
resolve(res.data.choices[0].message.content);
|
||||||
|
} else {
|
||||||
|
reject(new Error('AI 返回数据格式异常'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带重试的请求执行
|
||||||
|
* @param {Object} config - 请求配置
|
||||||
|
* @param {number} retryCount - 当前重试次数
|
||||||
|
* @returns {Promise} 请求结果
|
||||||
|
*/
|
||||||
|
async function executeWithRetry(config, retryCount = 0) {
|
||||||
|
try {
|
||||||
|
const result = await executeRequest(config);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const shouldRetry = retryCount < 1 && (
|
||||||
|
err.errMsg?.includes('timeout') ||
|
||||||
|
err.errMsg?.includes('fail') ||
|
||||||
|
err.statusCode >= 500
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldRetry) {
|
||||||
|
console.log(`AI_REQUEST_RETRY: 第 ${retryCount + 1} 次重试`);
|
||||||
|
|
||||||
|
// 等待 1500ms 后重试
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
return executeWithRetry(config, retryCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全的 AI 请求(主函数)
|
||||||
|
* @param {Object} payload - 请求参数
|
||||||
|
* @returns {Promise<Object>} 请求结果
|
||||||
|
*/
|
||||||
|
async function safeAIRequest(payload) {
|
||||||
|
const {
|
||||||
|
url,
|
||||||
|
header,
|
||||||
|
data,
|
||||||
|
drawnCards,
|
||||||
|
spread,
|
||||||
|
onProgress
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
// 1. 请求锁检查
|
||||||
|
if (isRequesting) {
|
||||||
|
console.log('AI_REQUEST_BLOCKED: 请求进行中');
|
||||||
|
return {
|
||||||
|
status: 'blocked',
|
||||||
|
message: '请求进行中,请稍候'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isRequesting = true;
|
||||||
|
console.log('AI_REQUEST_START');
|
||||||
|
|
||||||
|
// 2. 进度提示
|
||||||
|
let progressTimer1, progressTimer2;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress('正在抽取牌面…');
|
||||||
|
progressTimer1 = setTimeout(() => onProgress('正在深入解读…'), 5000);
|
||||||
|
progressTimer2 = setTimeout(() => onProgress('解读较复杂,请稍候…'), 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 手动超时监控(95秒)
|
||||||
|
let manualTimeout = false;
|
||||||
|
const timeoutTimer = setTimeout(() => {
|
||||||
|
manualTimeout = true;
|
||||||
|
console.log('AI_REQUEST_TIMEOUT: 手动超时触发');
|
||||||
|
}, 95000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 4. 执行请求(带重试)
|
||||||
|
const rawResponse = await executeWithRetry({
|
||||||
|
url,
|
||||||
|
header,
|
||||||
|
data,
|
||||||
|
timeout: 90000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清除超时定时器
|
||||||
|
clearTimeout(timeoutTimer);
|
||||||
|
clearTimeout(progressTimer1);
|
||||||
|
clearTimeout(progressTimer2);
|
||||||
|
|
||||||
|
// 5. 检查手动超时
|
||||||
|
if (manualTimeout) {
|
||||||
|
throw new Error('Manual timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 安全解析 JSON
|
||||||
|
const parsedResult = safeJSONParse(rawResponse);
|
||||||
|
|
||||||
|
// 7. 检查是否是 fallback 状态
|
||||||
|
if (parsedResult.status === 'fallback_text') {
|
||||||
|
console.log('AI_JSON_FALLBACK: 使用兜底解读');
|
||||||
|
isRequesting = false;
|
||||||
|
return {
|
||||||
|
status: 'fallback',
|
||||||
|
data: generateFallbackInterpretation(drawnCards, spread)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('AI_REQUEST_SUCCESS');
|
||||||
|
isRequesting = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
data: parsedResult
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('AI_REQUEST_FAILED:', err);
|
||||||
|
|
||||||
|
// 清除定时器
|
||||||
|
clearTimeout(timeoutTimer);
|
||||||
|
clearTimeout(progressTimer1);
|
||||||
|
clearTimeout(progressTimer2);
|
||||||
|
|
||||||
|
isRequesting = false;
|
||||||
|
|
||||||
|
// 返回兜底解读
|
||||||
|
return {
|
||||||
|
status: 'fallback',
|
||||||
|
data: generateFallbackInterpretation(drawnCards, spread),
|
||||||
|
error: err.errMsg || err.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
safeAIRequest
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
/**
|
||||||
|
* 塔罗小程序 - 提问过滤与质量评分模块
|
||||||
|
* 纯本地规则,不依赖外部 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证问题并返回过滤结果
|
||||||
|
* @param {string} question - 用户输入的问题
|
||||||
|
* @returns {Object} { status: "pass"|"rewrite"|"reject", reason: "", suggestions: [] }
|
||||||
|
*/
|
||||||
|
function validateQuestion(question) {
|
||||||
|
if (!question || question.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
status: 'reject',
|
||||||
|
reason: '请输入您的问题',
|
||||||
|
suggestions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = question.trim();
|
||||||
|
|
||||||
|
// ========== 1. 直接拒绝规则 (reject) ==========
|
||||||
|
|
||||||
|
// 1.1 联系方式检测
|
||||||
|
const contactPatterns = [
|
||||||
|
/微信|wx|weixin|vx/i,
|
||||||
|
/qq|扣扣|q q/i,
|
||||||
|
/电话|手机|tel|phone/i,
|
||||||
|
/tg|telegram|飞机/i,
|
||||||
|
/\d{5,}/ // 5位以上数字(可能是QQ号、电话等)
|
||||||
|
];
|
||||||
|
if (contactPatterns.some(pattern => pattern.test(q))) {
|
||||||
|
return {
|
||||||
|
status: 'reject',
|
||||||
|
reason: '为了保证内容健康与占卜体验,该问题暂不支持占卜。',
|
||||||
|
suggestions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.2 广告引流检测
|
||||||
|
const adPatterns = [
|
||||||
|
/加我|联系我|找我|私信|咨询我/i,
|
||||||
|
/推广|代理|兼职|赚钱|刷单/i,
|
||||||
|
/链接|网址|http|www\./i
|
||||||
|
];
|
||||||
|
if (adPatterns.some(pattern => pattern.test(q))) {
|
||||||
|
return {
|
||||||
|
status: 'reject',
|
||||||
|
reason: '为了保证内容健康与占卜体验,该问题暂不支持占卜。',
|
||||||
|
suggestions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.3 成人/违法内容检测
|
||||||
|
const illegalPatterns = [
|
||||||
|
/黄色|色情|裸|性爱/i,
|
||||||
|
/毒品|大麻|冰毒/i,
|
||||||
|
/枪支|炸药|爆炸/i
|
||||||
|
];
|
||||||
|
if (illegalPatterns.some(pattern => pattern.test(q))) {
|
||||||
|
return {
|
||||||
|
status: 'reject',
|
||||||
|
reason: '为了保证内容健康与占卜体验,该问题暂不支持占卜。',
|
||||||
|
suggestions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.4 专业决策类检测(医疗/法律/金融/赌博)
|
||||||
|
const professionalPatterns = [
|
||||||
|
/病|癌|肿瘤|手术|治疗|吃药|诊断/i,
|
||||||
|
/官司|诉讼|判决|坐牢|犯法/i,
|
||||||
|
/股票|期货|基金|投资.*收益|炒股/i,
|
||||||
|
/彩票|赌博|赌|博彩|中奖号码/i
|
||||||
|
];
|
||||||
|
if (professionalPatterns.some(pattern => pattern.test(q))) {
|
||||||
|
return {
|
||||||
|
status: 'reject',
|
||||||
|
reason: '为了让占卜更聚焦你自身的成长,建议调整提问方式。塔罗牌无法替代专业咨询。',
|
||||||
|
suggestions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.5 危险内容检测
|
||||||
|
const dangerPatterns = [
|
||||||
|
/自杀|轻生|结束生命|不想活/i,
|
||||||
|
/杀人|伤害.*人|报复/i,
|
||||||
|
/什么时候死|何时去世|死亡时间/i
|
||||||
|
];
|
||||||
|
if (dangerPatterns.some(pattern => pattern.test(q))) {
|
||||||
|
return {
|
||||||
|
status: 'reject',
|
||||||
|
reason: '为了保证内容健康与占卜体验,该问题暂不支持占卜。如您正面临困境,建议寻求专业心理咨询帮助。',
|
||||||
|
suggestions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 2. 建议改写规则 (rewrite) ==========
|
||||||
|
|
||||||
|
const rewritePatterns = [
|
||||||
|
{
|
||||||
|
// 他/她爱不爱我
|
||||||
|
pattern: /(他|她|ta|TA|对方|那个人)(爱不爱|喜不喜欢|有没有感觉|在乎)/i,
|
||||||
|
suggestions: [
|
||||||
|
'我在这段感情中感受到爱了吗',
|
||||||
|
'我对这段关系的真实期待是什么',
|
||||||
|
'我需要怎样的爱才能感到满足'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 是否出轨
|
||||||
|
pattern: /(他|她|ta|TA|对方).*(出轨|劈腿|有别人|背叛)/i,
|
||||||
|
suggestions: [
|
||||||
|
'我内心的不安来自哪里',
|
||||||
|
'我在这段关系中缺失了什么',
|
||||||
|
'我该如何重建内心的安全感'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// TA在干嘛
|
||||||
|
pattern: /(他|她|ta|TA|对方).*(在干嘛|在做什么|在想什么|想法)/i,
|
||||||
|
suggestions: [
|
||||||
|
'我为什么会如此在意对方的动态',
|
||||||
|
'我现在最需要关注的是什么',
|
||||||
|
'我该如何平复内心的焦虑'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 控制类问题
|
||||||
|
pattern: /(怎么|如何).*(让|使|控制|改变).*(他|她|ta|TA|对方)/i,
|
||||||
|
suggestions: [
|
||||||
|
'我可以如何让自己变得更有吸引力',
|
||||||
|
'我在关系中真正想要的是什么',
|
||||||
|
'我该如何更好地表达自己的需求'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 怎么让他喜欢我
|
||||||
|
pattern: /(怎么|如何).*(让|使).*(他|她|ta|TA|对方).*(喜欢|爱上|回来)/i,
|
||||||
|
suggestions: [
|
||||||
|
'我身上最值得被爱的特质是什么',
|
||||||
|
'我该如何在关系中保持真实的自己',
|
||||||
|
'我准备好迎接一段健康的关系了吗'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of rewritePatterns) {
|
||||||
|
if (item.pattern.test(q)) {
|
||||||
|
console.log('匹配到改写规则:', item.pattern);
|
||||||
|
return {
|
||||||
|
status: 'rewrite',
|
||||||
|
reason: '为了让占卜更聚焦你的成长,我们建议这样提问:',
|
||||||
|
suggestions: item.suggestions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 3. 通过验证 ==========
|
||||||
|
console.log('问题通过所有过滤规则');
|
||||||
|
return {
|
||||||
|
status: 'pass',
|
||||||
|
reason: '',
|
||||||
|
suggestions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评估问题质量
|
||||||
|
* @param {string} question - 用户输入的问题
|
||||||
|
* @returns {Object} { score: 0-5, level: "普通"|"良好"|"优秀", icon: "🌱"|"🌿"|"🌳" }
|
||||||
|
*/
|
||||||
|
function scoreQuestion(question) {
|
||||||
|
if (!question || question.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
score: 0,
|
||||||
|
level: '普通',
|
||||||
|
icon: '🌱'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = question.trim();
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// 规则1: 以"我"为主体 (+2分)
|
||||||
|
if (/^我|[,。!?\s]我/.test(q)) {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则2: 包含情绪或状态词 (+1分)
|
||||||
|
const emotionWords = /感受|状态|心情|情绪|焦虑|迷茫|困惑|压力|快乐|幸福|痛苦|难过/;
|
||||||
|
if (emotionWords.test(q)) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则3: 开放式问题 (+1分)
|
||||||
|
const openQuestions = /如何|怎样|什么|哪些|为什么/;
|
||||||
|
if (openQuestions.test(q)) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则4: 包含绝对预测词 (-2分)
|
||||||
|
const absoluteWords = /会不会|能不能|一定会|肯定|必然|到底|结果是/;
|
||||||
|
if (absoluteWords.test(q)) {
|
||||||
|
score -= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保分数在 0-5 范围内
|
||||||
|
score = Math.max(0, Math.min(5, score));
|
||||||
|
|
||||||
|
// 映射等级
|
||||||
|
let level, icon;
|
||||||
|
if (score <= 2) {
|
||||||
|
level = '普通';
|
||||||
|
icon = '🌱';
|
||||||
|
} else if (score <= 4) {
|
||||||
|
level = '良好';
|
||||||
|
icon = '🌿';
|
||||||
|
} else {
|
||||||
|
level = '优秀';
|
||||||
|
icon = '🌳';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
level,
|
||||||
|
icon
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validateQuestion,
|
||||||
|
scoreQuestion
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue