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} 请求结果 + */ +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 +}; diff --git a/utils/questionFilter.js b/utils/questionFilter.js new file mode 100644 index 0000000..97ed258 --- /dev/null +++ b/utils/questionFilter.js @@ -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 +};