diff --git a/pages/index/index.js b/pages/index/index.js
index f86e03d..317cdf0 100644
--- a/pages/index/index.js
+++ b/pages/index/index.js
@@ -6,6 +6,8 @@ const DEEPSEEK_BASE_URL = 'https://api.deepseek.com/chat/completions';
const { minorArcana } = require('../../data/tarot/index');
const { getQuestionType } = require('../../utils/questionType');
const { generateSpreadStory } = require('../../utils/spreadStory');
+const { validateQuestion, scoreQuestion } = require('../../utils/questionFilter');
+const { safeAIRequest } = require('../../utils/aiRequestManager');
// --- 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": "用一句话概括本次解读的核心主题",
- "status": "描述用户当下的情绪或处境状态",
- "influence": "分析当前局面背后的潜在影响因素(内在或外在)",
- "advice": "给出具体、温和且具有操作性的行动建议",
+ "theme": "直接回应用户问题的核心主题(必须提及问题关键词)",
+ "status": "用户在这个问题上的当前状态",
+ "influence": "影响这个问题的潜在因素",
+ "advice": "温和、可执行的个人成长建议",
"positions": [
{
"posName": "位置名称",
- "posMeaning": "由你结合牌面对该位置的详细解读(约 60-100 字)"
+ "posMeaning": "解释这张牌如何影响该问题情境(60-100字,必须联系问题)"
}
]
}`;
@@ -183,6 +260,7 @@ Page({
selectedSpread: null,
question: '', // 用户输入的问题
questionType: '综合问题', // 问题类型
+ questionQuality: null, // 问题质量评分
drawnCardIndices: [], // 用户选中的牌索引
drawnCards: [], // 实际抽到的牌对象
revealedCount: 0, // 已翻开的数量
@@ -196,10 +274,11 @@ Page({
infoAnimation: {},
guideAnimation: {},
- // AI 相关
+ // AI 解读相关
+ aiResult: null,
isAiLoading: false,
- aiExplanation: '',
- spreadStory: '', // 综合故事线
+ aiLoadingText: '正在抽取牌面…', // 动态加载提示
+ spreadStory: '',
// 3. 前缀文案列表(随机抽取)
prefixList: [
@@ -422,8 +501,65 @@ Page({
// --- 1.5 确认问题并开始洗牌 ---
confirmQuestion: function () {
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({
questionType: questionType,
state: 'shuffling'
@@ -437,8 +573,11 @@ Page({
// 输入问题
onQuestionInput: function (e) {
+ const question = e.detail.value;
+ const quality = scoreQuestion(question);
this.setData({
- question: e.detail.value
+ question: question,
+ questionQuality: quality
});
},
@@ -508,13 +647,14 @@ Page({
},
// --- 获取 AI 解读主逻辑 ---
- fetchAiInterpretation: function () {
- const { drawnCards, selectedSpread } = this.data;
+ fetchAiInterpretation: async function () {
+ const { drawnCards, selectedSpread, question, questionType } = this.data;
if (!drawnCards.length) return;
this.setData({
isAiLoading: true,
- aiResult: null
+ aiResult: null,
+ aiLoadingText: '正在抽取牌面…'
});
// 1. 构建卡牌信息
@@ -522,13 +662,39 @@ Page({
return `位置[${selectedSpread.positions[index]}]:${card.name} (${card.isReversed ? '逆位' : '正位'}),关键词:${card.keyword || (card.keywords ? card.keywords.join(',') : '')}`;
}).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("用户问题:", question);
+ console.log("提问领域:", questionType);
- wx.request({
+ // 4. 使用稳态请求管理器
+ const result = await safeAIRequest({
url: DEEPSEEK_BASE_URL,
- method: 'POST',
header: {
'Authorization': `Bearer ${DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
@@ -542,30 +708,42 @@ Page({
stream: false,
response_format: { type: "json_object" }
},
- timeout: 20000,
- success: (res) => {
- if (res.data && res.data.choices && res.data.choices[0]) {
- try {
- 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();
+ drawnCards,
+ spread: selectedSpread,
+ onProgress: (text) => {
+ this.setData({ aiLoadingText: text });
}
});
+
+ // 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 () {
diff --git a/pages/index/index.wxml b/pages/index/index.wxml
index 7a75dbd..d1d90f5 100644
--- a/pages/index/index.wxml
+++ b/pages/index/index.wxml
@@ -34,6 +34,12 @@
maxlength="200"
>
{{question.length}}/200
+
+
+
+ {{questionQuality.icon}}
+ 提问质量:{{questionQuality.level}}
+
@@ -143,7 +149,7 @@
···
- 正在深度解读能量流向...
+ {{aiLoadingText}}
diff --git a/pages/index/index.wxss b/pages/index/index.wxss
index 21b7601..435dbbf 100644
--- a/pages/index/index.wxss
+++ b/pages/index/index.wxss
@@ -521,3 +521,27 @@ page {
transform: scale(0.98);
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;
+}
+
diff --git a/utils/aiRequestManager.js b/utils/aiRequestManager.js
new file mode 100644
index 0000000..3304344
--- /dev/null
+++ b/utils/aiRequestManager.js
@@ -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