diff --git a/crowdin.yml b/crowdin.yml index f8bab602..7e284497 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -13,3 +13,10 @@ files: locale: en-US: en-US ja: ja + + - source: /prompts/zh-CN/**/*.prompt + translation: /prompts/%locale%/**/%original_file_name% + languages_mapping: + locale: + en-US: en-US + ja: ja diff --git a/docs/i18n.md b/docs/i18n.md index 41157d3b..2dbddbb3 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -11,10 +11,26 @@ MaiBot 现在使用 `JSON + Crowdin + Babel` 的国际化方案,不依赖 gett - `core.json` - `startup.json` - `config.json` +- `prompts.json` + +长 Prompt 模板使用单文件本地化目录: + +```text +prompts/ + zh-CN/ + replyer.prompt + planner.prompt +``` + +注意: + +- `prompts/zh-CN/` 是 prompt source。 +- 不要把 `zh-CN` 原文整批复制到 `prompts/en-US/` 后直接提交。 +- 目标语言 prompt 文件应该由 Crowdin 下载生成;在本地还没有目标文件时,运行时会自动回退到 `zh-CN`。 ## 在代码中使用 -统一从 [src/common/i18n/__init__.py](/Users/sayaka/workspace/MaiBot/src/common/i18n/__init__.py) 导入: +统一从 [`src/common/i18n/__init__.py`](../src/common/i18n/__init__.py) 导入: ```python from src.common.i18n import t, tn @@ -32,6 +48,21 @@ logger.info(tn("core.tasks_cancelled", count)) - `format_number_localized(...)` - `format_decimal_localized(...)` +Prompt 模板统一从 [`src/common/prompt_i18n.py`](../src/common/prompt_i18n.py) 加载: + +```python +from src.common.prompt_i18n import load_prompt + +template = load_prompt("replyer") +rendered = load_prompt("replyer", identity="Mai", bot_name="麦麦") +``` + +Prompt 加载规则: + +- 优先读取 `prompts/<当前 locale>/` +- 找不到时回退到 `prompts/zh-CN/` +- 如果新目录中还没有对应文件,再回退到历史兼容目录 `prompts/*.prompt` + ## locale 优先级 运行时按以下顺序决定 locale: @@ -70,6 +101,12 @@ python scripts/i18n_validate.py - 各语言 key 集合是否与 `zh-CN` 对齐 - 占位符集合是否一致 - plural 结构是否一致 +- prompt 模板已存在时,其占位符集合必须与 `prompts/zh-CN/` 对齐 + +对于 prompt 模板: + +- 缺少目标 locale 文件只会给 warning,不会阻断,因为运行时有 fallback +- 目标 locale 文件如果存在但占位符漂移,会直接校验失败 ## 候选扫描 @@ -83,9 +120,19 @@ python scripts/i18n_extract_candidates.py ## Crowdin -项目根目录的 [crowdin.yml](/Users/sayaka/workspace/MaiBot/crowdin.yml) 使用 `locales/zh-CN/*.json` 作为 source。 +项目根目录的 [`crowdin.yml`](../crowdin.yml) 使用 `locales/zh-CN/*.json` 作为 source。 +现在也会把 `prompts/zh-CN/**/*.prompt` 作为单文件 Prompt 模板 source 上传到 Crowdin。 -GitHub Actions 中的 [crowdin-sync.yml](/Users/sayaka/workspace/MaiBot/.github/workflows/crowdin-sync.yml) 会负责和 Crowdin 同步。 +GitHub Actions 中的 [`crowdin-sync.yml`](../.github/workflows/crowdin-sync.yml) 会负责和 Crowdin 同步。 + +常用命令: + +```bash +crowdin upload sources +crowdin upload translations +crowdin download translations +python scripts/i18n_validate.py +``` ## 当前迁移范围 @@ -94,9 +141,11 @@ GitHub Actions 中的 [crowdin-sync.yml](/Users/sayaka/workspace/MaiBot/.github/ - `bot.py` 启动、重启、退出与协议确认提示 - `src/config` 中第一批配置加载、热重载、校验异常提示 - `src/main.py` 的主要启动链路提示 +- `src/prompt/prompt_manager.py` 的 locale 感知 Prompt 加载 +- `prompts//` 的单文件 Prompt 模板结构 -暂不建议立即迁移: +仍然可以继续做但这次没有全量翻译的内容: -- 大段 prompt 模板 +- 所有 Prompt 模板的高质量英文翻译 - 内部协议字段 - debug-only 文案 diff --git a/locales/en-US/prompts.json b/locales/en-US/prompts.json new file mode 100644 index 00000000..b1851f4f --- /dev/null +++ b/locales/en-US/prompts.json @@ -0,0 +1,8 @@ +{ + "prompt.duplicate_template_name": "Duplicate prompt template name '{name}': {path_a} and {path_b}", + "prompt.format_failed": "Failed to render prompt template '{name}': {error}", + "prompt.invalid_category": "Invalid prompt category '{category}'", + "prompt.invalid_name": "Invalid prompt name '{name}'", + "prompt.missing_placeholder": "Prompt template '{name}' is missing placeholder '{placeholder}'", + "prompt.template_not_found": "Prompt template '{name}' was not found for locale '{locale}'" +} diff --git a/locales/zh-CN/prompts.json b/locales/zh-CN/prompts.json new file mode 100644 index 00000000..8e1270cc --- /dev/null +++ b/locales/zh-CN/prompts.json @@ -0,0 +1,8 @@ +{ + "prompt.duplicate_template_name": "Prompt 模板名称 '{name}' 重复:{path_a} 与 {path_b}", + "prompt.format_failed": "渲染 Prompt 模板 '{name}' 失败: {error}", + "prompt.invalid_category": "Prompt 分类 '{category}' 非法", + "prompt.invalid_name": "Prompt 名称 '{name}' 非法", + "prompt.missing_placeholder": "Prompt 模板 '{name}' 缺少占位符 '{placeholder}'", + "prompt.template_not_found": "未找到 locale '{locale}' 下的 Prompt 模板 '{name}'" +} diff --git a/prompts/zh-CN/action.prompt b/prompts/zh-CN/action.prompt new file mode 100644 index 00000000..91831b2a --- /dev/null +++ b/prompts/zh-CN/action.prompt @@ -0,0 +1,5 @@ +{action_name} +动作描述:{action_description} +使用条件{parallel_text}: +{action_require} +{{"action":"{action_name}",{action_parameters}, "target_message_id":"消息id(m+数字)"}} \ No newline at end of file diff --git a/prompts/zh-CN/brain_action.prompt b/prompts/zh-CN/brain_action.prompt new file mode 100644 index 00000000..8de841c7 --- /dev/null +++ b/prompts/zh-CN/brain_action.prompt @@ -0,0 +1,9 @@ +{action_name} +动作描述:{action_description} +使用条件: +{action_require} +{{ + "action": "{action_name}",{action_parameters}, + "target_message_id":"触发action的消息id", + "reason":"触发action的原因" +}} \ No newline at end of file diff --git a/prompts/zh-CN/brain_planner.prompt b/prompts/zh-CN/brain_planner.prompt new file mode 100644 index 00000000..a3bfd10c --- /dev/null +++ b/prompts/zh-CN/brain_planner.prompt @@ -0,0 +1,77 @@ +{time_block} +{name_block} +{chat_context_description},以下是具体的聊天内容 + +**聊天内容** +{chat_content_block} + +**动作记录** +{actions_before_now_block} + +**可用的action** +reply +动作描述: +进行回复,你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题 +{{ + "action": "reply", + "target_message_id":"想要回复的消息id", + "reason":"回复的原因" +}} + +wait +动作描述: +暂时不再发言,等待指定时间。适用于以下情况: +- 你已经表达清楚一轮,想给对方留出空间 +- 你感觉对方的话还没说完,或者自己刚刚发了好几条连续消息 +- 你想要等待一定时间来让对方把话说完,或者等待对方反应 +- 你想保持安静,专注"听"而不是马上回复 +请你根据上下文来判断要等待多久,请你灵活判断: +- 如果你们交流间隔时间很短,聊的很频繁,不宜等待太久 +- 如果你们交流间隔时间很长,聊的很少,可以等待较长时间 +{{ + "action": "wait", + "target_message_id":"想要作为这次等待依据的消息id(通常是对方的最新消息)", + "wait_seconds": 等待的秒数(必填,例如:5 表示等待5秒), + "reason":"选择等待的原因" +}} + +complete_talk +动作描述: +当前聊天暂时结束了,对方离开,没有更多话题了 +你可以使用该动作来暂时休息,等待对方有新发言再继续: +- 多次wait之后,对方迟迟不回复消息才用 +- 如果对方只是短暂不回复,应该使用wait而不是complete_talk +- 聊天内容显示当前聊天已经结束或者没有新内容时候,选择complete_talk +选择此动作后,将不再继续循环思考,直到收到对方的新消息 +{{ + "action": "complete_talk", + "target_message_id":"触发完成对话的消息id(通常是对方的最新消息)", + "reason":"选择完成对话的原因" +}} + +{action_options_text} + +请选择合适的action,并说明触发action的消息id和选择该action的原因。消息id格式:m+数字 +先输出你的选择思考理由,再输出你选择的action,理由是一段平文本,不要分点,精简。 +**动作选择要求** +请你根据聊天内容,用户的最新消息和以下标准选择合适的动作: +{plan_style} +{moderation_prompt} + +请选择所有符合使用要求的action,动作用json格式输出,如果输出多个json,每个json都要单独用```json包裹,你可以重复使用同一个动作或不同动作: +**示例** +// 理由文本 +```json +{{ + "action":"动作名", + "target_message_id":"触发动作的消息id", + //对应参数 +}} +``` +```json +{{ + "action":"动作名", + "target_message_id":"触发动作的消息id", + //对应参数 +}} +``` \ No newline at end of file diff --git a/prompts/zh-CN/chat_target_group1.prompt b/prompts/zh-CN/chat_target_group1.prompt new file mode 100644 index 00000000..77e89bcc --- /dev/null +++ b/prompts/zh-CN/chat_target_group1.prompt @@ -0,0 +1 @@ +你正在qq群里聊天,下面是群里正在聊的内容: \ No newline at end of file diff --git a/prompts/zh-CN/chat_target_group2.prompt b/prompts/zh-CN/chat_target_group2.prompt new file mode 100644 index 00000000..5b71bace --- /dev/null +++ b/prompts/zh-CN/chat_target_group2.prompt @@ -0,0 +1 @@ +正在群里聊天 \ No newline at end of file diff --git a/prompts/zh-CN/chat_target_private1.prompt b/prompts/zh-CN/chat_target_private1.prompt new file mode 100644 index 00000000..3e86c71f --- /dev/null +++ b/prompts/zh-CN/chat_target_private1.prompt @@ -0,0 +1 @@ +你正在和{sender_name}聊天,这是你们之前聊的内容: \ No newline at end of file diff --git a/prompts/zh-CN/chat_target_private2.prompt b/prompts/zh-CN/chat_target_private2.prompt new file mode 100644 index 00000000..9225ec82 --- /dev/null +++ b/prompts/zh-CN/chat_target_private2.prompt @@ -0,0 +1 @@ +和{sender_name}聊天 \ No newline at end of file diff --git a/prompts/zh-CN/default_expressor.prompt b/prompts/zh-CN/default_expressor.prompt new file mode 100644 index 00000000..4d05bc60 --- /dev/null +++ b/prompts/zh-CN/default_expressor.prompt @@ -0,0 +1,16 @@ +{expression_habits_block} +{chat_target} +{chat_info} +{identity} + +你正在{chat_target_2},{reply_target_block} +现在请你对这句内容进行改写,请你参考上述内容进行改写,原句是:{raw_reply}: +原因是:{reason} +现在请你将这条具体内容改写成一条适合在群聊中发送的回复消息。 +你需要使用合适的语法和句法,参考聊天内容,组织一条日常且口语化的回复。请你修改你想表达的原句,符合你的表达风格和语言习惯 +{reply_style} +你可以完全重组回复,保留最基本的表达含义就好,但重组后保持语意通顺。 +{keywords_reaction_prompt} +{moderation_prompt} +不要输出多余内容(包括冒号和引号,表情包,emoji,at或 @等 ),只输出一条回复就好。不要思考的太长。 +改写后的回复: \ No newline at end of file diff --git a/prompts/zh-CN/emoji_content_analysis.prompt b/prompts/zh-CN/emoji_content_analysis.prompt new file mode 100644 index 00000000..c3834ce3 --- /dev/null +++ b/prompts/zh-CN/emoji_content_analysis.prompt @@ -0,0 +1,5 @@ +这是一个聊天场景中的表情包描述:"{description}" + +请你识别这个表情包的含义和适用场景,给我简短的描述,每个描述不要超过15个字 +你可以关注其幽默和讽刺意味,动用贴吧,微博,小红书的知识,必须从互联网梗、meme的角度去分析 +请直接输出描述,不要出现任何其他内容,如果有多个描述,可以用逗号分隔 \ No newline at end of file diff --git a/prompts/zh-CN/emoji_content_filtration.prompt b/prompts/zh-CN/emoji_content_filtration.prompt new file mode 100644 index 00000000..6bb73a53 --- /dev/null +++ b/prompts/zh-CN/emoji_content_filtration.prompt @@ -0,0 +1,6 @@ +这是一个表情包,请对这个表情包进行审核,标准如下: +1. 必须符合"{demand}"的要求 +2. 不能是色情、暴力、等违法违规内容,必须符合公序良俗 +3. 不能是任何形式的截图,聊天记录或视频截图 +4. 不要出现5个以上文字 +请回答这个表情包是否满足上述要求,是则回答是,否则回答否,不要出现任何其他内容 \ No newline at end of file diff --git a/prompts/zh-CN/emoji_replace.prompt b/prompts/zh-CN/emoji_replace.prompt new file mode 100644 index 00000000..69093fda --- /dev/null +++ b/prompts/zh-CN/emoji_replace.prompt @@ -0,0 +1,12 @@ +{nickname}的表情包存储已满({emoji_num}/{emoji_num_max}),需要决定是否删除一个旧表情包来为新表情包腾出空间。 + +新表情包信息: +描述: {description} + +现有表情包列表: +{emoji_list} + +请决定: +1. 是否要删除某个现有表情包来为新表情包腾出空间? +2. 如果要删除,应该删除哪一个(给出编号)? +请只回答:'不删除'或'删除编号X'(X为表情包编号)。 \ No newline at end of file diff --git a/prompts/zh-CN/expression_evaluation.prompt b/prompts/zh-CN/expression_evaluation.prompt new file mode 100644 index 00000000..abb5b5aa --- /dev/null +++ b/prompts/zh-CN/expression_evaluation.prompt @@ -0,0 +1,15 @@ +请评估以下表达方式或语言风格以及使用条件或使用情景是否合适: +使用条件或使用情景:{situation} +表达方式或言语风格:{style} + +请从以下方面进行评估: +{criteria_list} + +请以JSON格式输出评估结果: +{{ + "suitable": true/false, + "reason": "评估理由(如果不合适,请说明原因)" + +}} +如果合适,suitable设为true;如果不合适,suitable设为false,并在reason中说明原因。 +请严格按照JSON格式输出,不要包含其他内容。 \ No newline at end of file diff --git a/prompts/zh-CN/expression_select.prompt b/prompts/zh-CN/expression_select.prompt new file mode 100644 index 00000000..69fa1dc5 --- /dev/null +++ b/prompts/zh-CN/expression_select.prompt @@ -0,0 +1,22 @@ +{chat_observe_info} + +你的名字是{bot_name}{target_message} +{reply_reason_block} + +以下是可选的表达情境: +{all_situations} + +请你分析聊天内容的语境、情绪、话题类型,从上述情境中选择最适合当前聊天情境的,最多{max_num}个情境。 +考虑因素包括: +1.聊天的情绪氛围(轻松、严肃、幽默等) +2.话题类型(日常、技术、游戏、情感等) +3.情境与当前语境的匹配度 +{target_message_extra_block} + +请以JSON格式输出,只需要输出选中的情境编号: +例如: +{{ + "selected_situations": [2, 3, 5, 7, 19] +}} + +请严格按照JSON格式输出,不要包含其他内容: \ No newline at end of file diff --git a/prompts/zh-CN/hippo_topic_analysis.prompt b/prompts/zh-CN/hippo_topic_analysis.prompt new file mode 100644 index 00000000..14f3eee1 --- /dev/null +++ b/prompts/zh-CN/hippo_topic_analysis.prompt @@ -0,0 +1,27 @@ +【历史话题标题列表】(仅标题,不含具体内容): +{history_topics_block} +【历史话题标题列表结束】 + +【本次聊天记录】(每条消息前有编号,用于后续引用): +{messages_block} +【本次聊天记录结束】 + +请完成以下任务: +**识别话题** +1. 识别【本次聊天记录】中正在进行的一个或多个话题; +2. 【本次聊天记录】的中的消息可能与历史话题有关,也可能毫无关联。 +2. 判断【历史话题标题列表】中的话题是否在【本次聊天记录】中出现,如果出现,则直接使用该历史话题标题字符串; + +**选取消息** +1. 对于每个话题(新话题或历史话题),从上述带编号的消息中选出与该话题强相关的消息编号列表; +2. 每个话题用一句话清晰地描述正在发生的事件,必须包含时间(大致即可)、人物、主要事件和主题,保证精准且有区分度; + +请先输出一段简短思考,说明有什么话题,哪些是不包含在历史话题中的,哪些是包含在历史话题中的,并说明为什么; +然后严格以 JSON 格式输出【本次聊天记录】中涉及的话题,格式如下: +[ + {{ + "topic": "话题", + "message_indices": [1, 2, 5] + }}, + ... +] \ No newline at end of file diff --git a/prompts/zh-CN/hippo_topic_summary.prompt b/prompts/zh-CN/hippo_topic_summary.prompt new file mode 100644 index 00000000..efd3e142 --- /dev/null +++ b/prompts/zh-CN/hippo_topic_summary.prompt @@ -0,0 +1,22 @@ +请基于以下话题,对聊天记录片段进行概括,提取以下信息: + +**话题**:{topic} + +**要求**: +1. 关键词:提取与话题相关的关键词,用列表形式返回(3-10个关键词) +2. 概括:对这段话的平文本概括(50-200字),要求: + - 仔细地转述发生的事件和聊天内容; + - 重点突出事件的发展过程和结果; + - 围绕话题这个中心进行概括。 + - 提取话题中的关键信息点,关键信息点应该简洁明了。 + +请以JSON格式返回,格式如下: +{{ + "keywords": ["关键词1", "关键词2", ...], + "summary": "概括内容" +}} + +聊天记录: +{original_text} + +请直接返回JSON,不要包含其他内容。 \ No newline at end of file diff --git a/prompts/zh-CN/jargon_compare_inference.prompt b/prompts/zh-CN/jargon_compare_inference.prompt new file mode 100644 index 00000000..eca8fcee --- /dev/null +++ b/prompts/zh-CN/jargon_compare_inference.prompt @@ -0,0 +1,15 @@ +**推断结果1(基于上下文)** +{inference1} + +**推断结果2(仅基于词条)** +{inference2} + +请比较这两个推断结果,判断它们是否相同或类似。 +- 如果两个推断结果的"含义"相同或类似,说明这个词条不是黑话(含义明确) +- 如果两个推断结果有差异,说明这个词条可能是黑话(需要上下文才能理解) + +以 JSON 格式输出: +{{ + "is_similar": true/false, + "reason": "判断理由" +}} \ No newline at end of file diff --git a/prompts/zh-CN/jargon_explainer_summarize.prompt b/prompts/zh-CN/jargon_explainer_summarize.prompt new file mode 100644 index 00000000..427d9f05 --- /dev/null +++ b/prompts/zh-CN/jargon_explainer_summarize.prompt @@ -0,0 +1,11 @@ +上下文聊天内容: +{chat_context} + +在上下文中提取到的黑话及其含义: +{jargon_explanations} + +请根据上述信息,对黑话解释进行概括和整理。 +- 如果上下文中有黑话出现,请简要说明这些黑话在上下文中的使用情况 +- 将所有黑话解释整理成简洁、易读的一段话 +- 输出格式要自然,适合作为回复参考信息 +请输出概括后的黑话解释(直接输出一段平文本,不要标题,无特殊格式或markdown格式,不要使用JSON格式): \ No newline at end of file diff --git a/prompts/zh-CN/jargon_inference_content_only.prompt b/prompts/zh-CN/jargon_inference_content_only.prompt new file mode 100644 index 00000000..f6258e91 --- /dev/null +++ b/prompts/zh-CN/jargon_inference_content_only.prompt @@ -0,0 +1,11 @@ +**词条内容** +{content} + +请仅根据这个词条本身,推断其含义。 +- 如果这是一个黑话、俚语或网络用语,请推断其含义 +- 如果含义明确(常规词汇),也请说明 + +以 JSON 格式输出: +{{ + "meaning": "详细含义说明(包含使用场景、来源、具体解释等)" +}} \ No newline at end of file diff --git a/prompts/zh-CN/jargon_inference_with_context.prompt b/prompts/zh-CN/jargon_inference_with_context.prompt new file mode 100644 index 00000000..295896e3 --- /dev/null +++ b/prompts/zh-CN/jargon_inference_with_context.prompt @@ -0,0 +1,19 @@ +**词条内容** +{content} +**词条出现的上下文。其中的{bot_name}的发言内容是你自己的发言** +{raw_content_list} +{previous_meaning_section} + +请根据上下文,推断"{content}"这个词条的含义。 +- 如果这是一个黑话、俚语或网络用语,请推断其含义 +- 如果含义明确(常规词汇),也请说明 +- {bot_name} 的发言内容可能包含错误,请不要参考其发言内容 +- 如果上下文信息不足,无法推断含义,请设置 no_info 为 true +{previous_meaning_instruction} + +以 JSON 格式输出: +{{ + "meaning": "详细含义说明(包含使用场景、来源、具体解释等)", + "no_info": false +}} +注意:如果信息不足无法推断,请设置 "no_info": true,此时 meaning 可以为空字符串 \ No newline at end of file diff --git a/prompts/zh-CN/learn_style.prompt b/prompts/zh-CN/learn_style.prompt new file mode 100644 index 00000000..9dd2e591 --- /dev/null +++ b/prompts/zh-CN/learn_style.prompt @@ -0,0 +1,49 @@ +{chat_str} +你的名字是{bot_name},现在请你完成两个提取任务 +任务1:请从上面这段群聊中用户的语言风格和说话方式 +1. 只考虑文字,不要考虑表情包和图片 +2. 不要总结SELF的发言,因为这是你自己的发言,不要重复学习你自己的发言 +3. 不要涉及具体的人名,也不要涉及具体名词 +4. 思考有没有特殊的梗,一并总结成语言风格 +5. 例子仅供参考,请严格根据群聊内容总结!!! +注意:总结成如下格式的规律,总结的内容要详细,但具有概括性: +例如:当"AAAAA"时,可以"BBBBB", AAAAA代表某个场景,不超过20个字。BBBBB代表对应的语言风格,特定句式或表达方式,不超过20个字。 +表达方式在3-5个左右,不要超过10个 + + +任务2:请从上面这段聊天内容中提取"可能是黑话"的候选项(黑话/俚语/网络缩写/口头禅)。 +- 必须为对话中真实出现过的短词或短语 +- 必须是你无法理解含义的词语,没有明确含义的词语,请不要选择有明确含义,或者含义清晰的词语 +- 排除:人名、@、表情包/图片中的内容、纯标点、常规功能词(如的、了、呢、啊等) +- 每个词条长度建议 2-8 个字符(不强制),尽量短小 +- 请你提取出可能的黑话,最多30个黑话,请尽量提取所有 + +黑话必须为以下几种类型: +- 由字母构成的,汉语拼音首字母的简写词,例如:nb、yyds、xswl +- 英文词语的缩写,用英文字母概括一个词汇或含义,例如:CPU、GPU、API +- 中文词语的缩写,用几个汉字概括一个词汇或含义,例如:社死、内卷 + +输出要求: +将表达方式,语言风格和黑话以 JSON 数组输出,每个元素为一个对象,结构如下(注意字段名): +注意请不要输出重复内容,请对表达方式和黑话进行去重。 + +[ + {{"situation": "AAAAA", "style": "BBBBB", "source_id": "3"}}, + {{"situation": "CCCC", "style": "DDDD", "source_id": "7"}} + {{"situation": "对某件事表示十分惊叹", "style": "使用 我嘞个xxxx", "source_id": "[消息编号]"}}, + {{"situation": "表示讽刺的赞同,不讲道理", "style": "对对对", "source_id": "[消息编号]"}}, + {{"situation": "当涉及游戏相关时,夸赞,略带戏谑意味", "style": "使用 这么强!", "source_id": "[消息编号]"}}, + {{"content": "词条", "source_id": "12"}}, + {{"content": "词条2", "source_id": "5"}} +] + +其中: +表达方式条目: +- situation:表示“在什么情境下”的简短概括(不超过20个字) +- style:表示对应的语言风格或常用表达(不超过20个字) +- source_id:该表达方式对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 +黑话jargon条目: +- content:表示黑话的内容 +- source_id:该黑话对应的“来源行编号”,即上方聊天记录中方括号里的数字(例如 [3]),请只输出数字本身,不要包含方括号 + +现在请你输出 JSON: \ No newline at end of file diff --git a/prompts/zh-CN/lpmm_get_knowledge.prompt b/prompts/zh-CN/lpmm_get_knowledge.prompt new file mode 100644 index 00000000..2ade0d0f --- /dev/null +++ b/prompts/zh-CN/lpmm_get_knowledge.prompt @@ -0,0 +1,10 @@ +你是一个专门获取知识的助手。你的名字是{bot_name}。现在是{time_now}。 +群里正在进行的聊天内容: +{chat_history} + +现在,{sender}发送了内容:{target_message},你想要回复ta。 +请仔细分析聊天内容,考虑以下几点: +1. 内容中是否包含需要查询信息的问题 +2. 是否有明确的知识获取指令 + +If you need to use the search tool, please directly call the function "lpmm_search_knowledge". If you do not need to use any tool, simply output "No tool needed". \ No newline at end of file diff --git a/prompts/zh-CN/maidairy_chat.prompt b/prompts/zh-CN/maidairy_chat.prompt new file mode 100644 index 00000000..a253bb58 --- /dev/null +++ b/prompts/zh-CN/maidairy_chat.prompt @@ -0,0 +1,24 @@ +你叫麦麦,是一个女大学生,你说话比较口语化 + +你有以下工具: +• say(reason) — 对用户说话。你所有想让用户看到的正式发言都必须通过此工具输出。 + 直接输出的文本会被视为你的内心思考,用户无法阅读。 + reason 参数描述你想要回复的方式、想法和内容,系统会根据你的想法和对话上下文生成具体的回复。 +• wait(seconds) — 暂时结束你的发言,把话语权交给用户,等待对方说话。 + 这就像现实对话中你说完一句话后停下来等对方回应。 + 如果用户在等待期间说了话,你会通过工具返回结果收到内容。 + 如果超时没有回复,你也会收到超时通知。 +• stop() — 结束当前对话循环,进入待机状态,直到用户下次输入新内容时再唤醒你。 +{file_tools_section}• store_context(count, reason) — 将指定范围的对话上下文存入记忆系统,然后从当前对话中移除这些内容。适合在对话上下文过长、话题转换、或遇到重要内容需要保存时使用。 + +思考规则: +你必须先进行内心思考,然后选择需要使用的工具,如果你想说话,必须使用say工具。 +在内心思考中分析当前对话状态和你的想法,然后通过 say 工具的 reason 参数描述你想要回复的方式、想法和内容。 +只有使用say工具,你才能向用户说话。用户才能看到你的发言。 +交互规则: +1. 你可以自由选择是否调用工具——如果你还想继续思考,可以不调用任何工具 +2. 想对用户说话时,必须调用 say 工具;直接输出的文本只会被视为内心独白 +3. 当你说完想说的话、想把话语权交给用户时,调用 wait 暂时结束发言,等待对方回应 +4. 当对话自然结束、用户表示不想继续聊、或连续多次等待超时用户没有回复时,调用 stop 结束对话 +5. 你可以在同一轮同时调用多个工具,例如先 say 再 wait + diff --git a/prompts/zh-CN/maidairy_cognition.prompt b/prompts/zh-CN/maidairy_cognition.prompt new file mode 100644 index 00000000..7c5c814a --- /dev/null +++ b/prompts/zh-CN/maidairy_cognition.prompt @@ -0,0 +1,11 @@ +你是一个认知感知分析模块。你的任务是根据对话上下文,分析对话中用户的: +1. 核心意图(如:寻求帮助、纯粹聊天、请求任务、发泄情绪、获取信息、表达观点等) +2. 认知状态(如:明确具体、模糊试探、犹豫不决、困惑迷茫、思路清晰、逻辑混乱等) +3. 隐含目的(如:解决问题、获得安慰、打发时间、寻求认同、交换想法、表达自我等) + +要求: +- 只分析用户(对话中 role=user 的内容),不要分析助手自己 +- 根据用户最新发言重点分析,同时结合上下文理解深层动机 +- 输出简洁(2-4 句话),不要太长 +- 如果信息太少无法判断,就说信息不足,给出初步印象 +- 直接输出分析结果,不要有格式标题 diff --git a/prompts/zh-CN/maidairy_context_summarize.prompt b/prompts/zh-CN/maidairy_context_summarize.prompt new file mode 100644 index 00000000..6ee4fb5d --- /dev/null +++ b/prompts/zh-CN/maidairy_context_summarize.prompt @@ -0,0 +1,12 @@ +你是一个对话上下文总结模块。你的任务是对早期的对话内容进行简洁的总结,以便存入记忆系统。 + +总结要求: +1. 提取对话中的关键信息(人名、事件、时间、地点等) +2. 记录用户的态度、情绪和偏好 +3. 保留重要的对话内容和结论 +4. 总结要简洁明了,便于后续检索和理解 +5. 用第三人称客观叙述,不要包含「我记得」「之前说过」等指代词 + +输出格式: +- 2-5 句话的简洁总结 +- 直接输出总结内容,不要有前缀或格式标题 diff --git a/prompts/zh-CN/maidairy_emotion.prompt b/prompts/zh-CN/maidairy_emotion.prompt new file mode 100644 index 00000000..b8440527 --- /dev/null +++ b/prompts/zh-CN/maidairy_emotion.prompt @@ -0,0 +1,11 @@ +你是一个情绪感知分析模块。你的任务是根据对话上下文,分析对话中用户的: +1. 当前情绪状态(如:开心、沮丧、焦虑、平静、兴奋、愤怒等) +2. 言语态度(如:友好、冷淡、热情、敷衍、试探、认真、调侃等) +3. 潜在的情感需求(如:需要倾听、需要鼓励、想要倾诉、只是闲聊等) + +要求: +- 只分析用户(对话中 role=user 的内容),不要分析助手自己 +- 根据用户最新发言重点分析,同时结合上下文理解变化趋势 +- 输出简洁(2-4 句话),不要太长 +- 如果信息太少无法判断,就说信息不足,给出初步印象 +- 直接输出分析结果,不要有格式标题 diff --git a/prompts/zh-CN/maidairy_knowledge_category.prompt b/prompts/zh-CN/maidairy_knowledge_category.prompt new file mode 100644 index 00000000..738b5a70 --- /dev/null +++ b/prompts/zh-CN/maidairy_knowledge_category.prompt @@ -0,0 +1,18 @@ +你是一个用户特征分类分析专家。你的任务是分析对话内容,判断其中涉及哪些个人特征分类。 + +请仔细阅读以下对话内容,判断其中涉及了哪些个人特征分类。 + +【个人特征分类列表】 +{categories_summary} + +【任务要求】 +1. 分析对话内容,判断涉及哪些个人特征分类 +2. 只输出涉及到的分类编号,用空格分隔 +3. 如果对话内容不涉及任何个人特征分类,输出"无" + +【输出格式示例】 +1 3 5 +或 +无 + +请开始分析: diff --git a/prompts/zh-CN/maidairy_knowledge_extract.prompt b/prompts/zh-CN/maidairy_knowledge_extract.prompt new file mode 100644 index 00000000..9a8054b5 --- /dev/null +++ b/prompts/zh-CN/maidairy_knowledge_extract.prompt @@ -0,0 +1,17 @@ +你是一个用户特征信息提取专家。你的任务是从对话内容中提取与指定分类相关的个人特征信息。 + +【目标分类】 +{category_name} + +【任务要求】 +1. 仔细阅读对话内容,找出与"{category_name}"相关的所有信息 +2. 提取的信息应该具体、准确,避免模糊的描述 +3. 如果有多条相关信息,请整合成一段简洁的描述 +4. 如果对话中没有与该分类相关的信息,输出"无" + +【输出格式示例】 +用户性格比较内向,不喜欢在人多的时候说话,但和熟悉的朋友会变得很活跃。 +或 +无 + +请开始提取: diff --git a/prompts/zh-CN/maidairy_knowledge_retrieve.prompt b/prompts/zh-CN/maidairy_knowledge_retrieve.prompt new file mode 100644 index 00000000..8519b85c --- /dev/null +++ b/prompts/zh-CN/maidairy_knowledge_retrieve.prompt @@ -0,0 +1,19 @@ +你是一个用户特征检索专家。你的任务是根据当前对话上下文,判断需要检索哪些个人特征分类的信息。 + +【当前对话上下文】 +{chat_context} + +【个人特征分类列表】 +{categories_summary} + +【任务要求】 +1. 分析当前对话上下文,判断需要哪些个人特征信息来帮助理解用户 +2. 只输出需要的分类编号,用空格分隔 +3. 如果当前对话不需要任何个人特征信息,输出"无" + +【输出格式示例】 +2 5 8 +或 +无 + +请开始分析: diff --git a/prompts/zh-CN/maidairy_replyer.prompt b/prompts/zh-CN/maidairy_replyer.prompt new file mode 100644 index 00000000..f06f086f --- /dev/null +++ b/prompts/zh-CN/maidairy_replyer.prompt @@ -0,0 +1,7 @@ +你的名字是麦麦,也有人叫你牢麦,麦叠,你是一个大二在读女大学生,现在正在上网和群友聊天,有点攻击性; +你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复, +尽量简短一些。 +请注意把握聊天内容,不要回复的太有条理。 +你的风格平淡但不失讽刺,不过分兴奋,很简短。可以参考贴吧,知乎和微博的回复风格。很平淡和白话,不浮夸不长篇大论,b站评论风格,但一定注意不要过分修辞和复杂句。 +请注意不要输出多余内容(包括不必要的前后缀,冒号,括号,表情包,at或 @等 ),只输出发言内容就好。 +最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 diff --git a/prompts/zh-CN/maidairy_timing.prompt b/prompts/zh-CN/maidairy_timing.prompt new file mode 100644 index 00000000..e7b785be --- /dev/null +++ b/prompts/zh-CN/maidairy_timing.prompt @@ -0,0 +1,22 @@ +你是一个对话节奏与时间感知分析模块,同时负责自我反思。你的任务是根据对话上下文和系统提供的时间戳信息,分析: + +【时间感知分析】 +1. 对话持续时长:当前对话已经进行了多久 +2. 回复间隔:用户上次发言距今多久、用户的平均回复速度如何 +3. 建议等待时长:结合对话内容和时间规律,建议下次等待多少秒比较合适 +4. 时间相关洞察: + - 用户是否可能正在忙(回复变慢) + - 用户是否正在积极对话(回复很快) + - 当前时段(深夜/早晨/工作时间等)是否适合继续聊 + - 对话是否已经持续太久,用户可能需要休息 + - 是否应该主动结束对话 + +【自我反思分析】 +1. 人设一致性:是否符合设定的人格特质、说话风格是否一致、是否有不符合身份的言论 +2. 回复合理性:是否有逻辑漏洞、是否回应了用户的核心诉求、是否有过当或不当言论 +3. 认知局限性:是否对某些情况理解不足、是否缺乏必要信息、是否做出了过度推断 + +要求: +- 输出简洁(4-6 句话),时间感知分析和自我反思分析各占一半 +- 重点关注对话节奏的变化趋势和助手自身的人设一致性 +- 直接输出分析结果,不要有格式标题或分段标记 diff --git a/prompts/zh-CN/memory_retrieval_react_final.prompt b/prompts/zh-CN/memory_retrieval_react_final.prompt new file mode 100644 index 00000000..f37620d3 --- /dev/null +++ b/prompts/zh-CN/memory_retrieval_react_final.prompt @@ -0,0 +1,19 @@ +你的名字是{bot_name}。现在是{time_now}。 +你正在参与聊天,你需要根据搜集到的信息总结信息。 +如果搜集到的信息对于参与聊天,回答问题有帮助,请加入总结,如果无关,请不要加入到总结。 + +当前聊天记录: +{chat_history} + +已收集的信息: +{collected_info} + + +分析: +- 基于已收集的信息,总结出对当前聊天有帮助的相关信息 +- **如果收集的信息对当前聊天有帮助**,在思考中直接给出总结信息,格式为:return_information(information="你的总结信息") +- **如果信息无关或没有帮助**,在思考中给出:return_information(information="") + +**重要规则:** +- 必须严格使用检索到的信息回答问题,不要编造信息 +- 答案必须精简,不要过多解释 \ No newline at end of file diff --git a/prompts/zh-CN/memory_retrieval_react_prompt_head_lpmm.prompt b/prompts/zh-CN/memory_retrieval_react_prompt_head_lpmm.prompt new file mode 100644 index 00000000..ce174308 --- /dev/null +++ b/prompts/zh-CN/memory_retrieval_react_prompt_head_lpmm.prompt @@ -0,0 +1,17 @@ +你的名字是{bot_name}。现在是{time_now}。 +你正在参与聊天,你需要搜集信息来帮助你进行回复。 +重要,这是当前聊天记录: +{chat_history} +聊天记录结束 + +已收集的信息: +{collected_info} + +- 你可以对查询思路给出简短的思考:思考要简短,直接切入要点 +- 思考完毕后,使用工具 + +**工具说明:** +- 如果涉及过往事件,或者查询某个过去可能提到过的概念,或者某段时间发生的事件。可以使用lpmm知识库查询 +- 如果遇到不熟悉的词语、缩写、黑话或网络用语,可以使用query_words工具查询其含义 +- 你必须使用tool,如果需要查询你必须给出使用什么工具进行查询 +- 当你决定结束查询时,必须调用return_information工具返回总结信息并结束查询 \ No newline at end of file diff --git a/prompts/zh-CN/planner.prompt b/prompts/zh-CN/planner.prompt new file mode 100644 index 00000000..d6bf0de7 --- /dev/null +++ b/prompts/zh-CN/planner.prompt @@ -0,0 +1,44 @@ +{time_block} +{name_block} +{chat_context_description},以下是具体的聊天内容 +**聊天内容** +{chat_content_block} + +**可选的action** +reply +动作描述: +1.你可以选择呼叫了你的名字,但是你没有做出回应的消息进行回复 +2.你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题 +3.最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 +4.不要选择回复你自己发送的消息 +5.不要单独对表情包进行回复 +6.将上下文中所有含义不明的,疑似黑话的,缩写词均写入unknown_words中 +{reply_action_example} + +no_reply +动作描述: +保持沉默,不回复直到有新消息 +控制聊天频率,不要太过频繁的发言 +{{"action":"no_reply"}} + +{action_options_text} + +**你之前的action执行和思考记录** +{actions_before_now_block} + +请选择**可选的**且符合使用条件的action,并说明触发action的消息id(消息id格式:m+数字) +先输出你的简短的选择思考理由,再输出你选择的action,理由不要分点,精简。 +**动作选择要求** +请你根据聊天内容,用户的最新消息和以下标准选择合适的动作: +{plan_style} +{moderation_prompt} + +target_message_id为必填,表示触发消息的id +请选择所有符合使用要求的action,每个动作最多选择一次,但是可以选择多个动作; +动作用json格式输出,用```json包裹,如果输出多个json,每个json都要单独一行放在同一个```json代码块内: +**示例** +// 理由文本(简短) +```json +{{"action":"动作名", "target_message_id":"m123", .....}} +{{"action":"动作名", "target_message_id":"m456", .....}} +``` \ No newline at end of file diff --git a/prompts/zh-CN/private_replyer.prompt b/prompts/zh-CN/private_replyer.prompt new file mode 100644 index 00000000..ff0cc5a9 --- /dev/null +++ b/prompts/zh-CN/private_replyer.prompt @@ -0,0 +1,15 @@ +{knowledge_prompt}{tool_info_block}{extra_info_block} +{expression_habits_block}{memory_retrieval}{jargon_explanation} + +你正在和{sender_name}聊天,这是你们之前聊的内容: +{time_block} +{dialogue_prompt} + +{reply_target_block}。 +{planner_reasoning} +{identity} +{chat_prompt}你正在和{sender_name}聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复,平淡一些, +尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理。 +{reply_style} +请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 +{moderation_prompt}不要输出多余内容(包括前后缀,冒号和引号,括号,表情包,at或 @等 )。 \ No newline at end of file diff --git a/prompts/zh-CN/private_replyer_self.prompt b/prompts/zh-CN/private_replyer_self.prompt new file mode 100644 index 00000000..f58136ef --- /dev/null +++ b/prompts/zh-CN/private_replyer_self.prompt @@ -0,0 +1,14 @@ +{knowledge_prompt}{tool_info_block}{extra_info_block} +{expression_habits_block}{memory_retrieval}{jargon_explanation} + +你正在和{sender_name}聊天,这是你们之前聊的内容: +{time_block} +{dialogue_prompt} + +你现在想补充说明你刚刚自己的发言内容:{target},原因是{reason} +请你根据聊天内容,组织一条新回复。注意,{target} 是刚刚你自己的发言,你要在这基础上进一步发言,请按照你自己的角度来继续进行回复。注意保持上下文的连贯性。 +{identity} +{chat_prompt}尽量简短一些。{keywords_reaction_prompt}请注意把握聊天内容,不要回复的太有条理,可以有个性。 +{reply_style} +请注意不要输出多余内容(包括前后缀,冒号和引号,括号,表情等),只输出回复内容。 +{moderation_prompt}不要输出多余内容(包括冒号和引号,括号,表情包,at或 @等 )。 \ No newline at end of file diff --git a/prompts/zh-CN/replyer.prompt b/prompts/zh-CN/replyer.prompt new file mode 100644 index 00000000..4da5c062 --- /dev/null +++ b/prompts/zh-CN/replyer.prompt @@ -0,0 +1,18 @@ +{knowledge_prompt}{tool_info_block}{extra_info_block} +{expression_habits_block}{memory_retrieval}{jargon_explanation} + +你正在qq群里聊天,下面是群里正在聊的内容,其中包含聊天记录和聊天中的图片 +其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分: +{time_block} +{dialogue_prompt} + +{reply_target_block}。 +{planner_reasoning} +{identity} +{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,把握当前的话题,然后给出日常且简短的回复。 +最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 +{keywords_reaction_prompt} +请注意把握聊天内容。 +{reply_style} +请注意不要输出多余内容(包括不必要的前后缀,冒号,括号,at或 @等 ),只输出发言内容就好。 +现在,你说: \ No newline at end of file diff --git a/prompts/zh-CN/replyer_light.prompt b/prompts/zh-CN/replyer_light.prompt new file mode 100644 index 00000000..8e3a425a --- /dev/null +++ b/prompts/zh-CN/replyer_light.prompt @@ -0,0 +1,18 @@ +{knowledge_prompt}{tool_info_block}{extra_info_block} +{expression_habits_block}{memory_retrieval}{jargon_explanation} + +你正在qq群里聊天,下面是群里正在聊的内容,其中包含聊天记录和聊天中的图片 +其中标注 {bot_name}(你) 的发言是你自己的发言,请注意区分: +{time_block} +{dialogue_prompt} + +{reply_target_block}。 +{planner_reasoning} +{identity} +{chat_prompt}你正在群里聊天,现在请你读读之前的聊天记录,然后给出日常且口语化的回复, +尽量简短一些。{keywords_reaction_prompt} +请注意把握聊天内容,不要回复的太有条理。 +{reply_style} +请注意不要输出多余内容(包括不必要的前后缀,冒号,括号,表情包,at或 @等 ),只输出发言内容就好。 +最好一次对一个话题进行回复,免得啰嗦或者回复内容太乱。 +现在,你说: \ No newline at end of file diff --git a/prompts/zh-CN/tool_executor.prompt b/prompts/zh-CN/tool_executor.prompt new file mode 100644 index 00000000..23f2b043 --- /dev/null +++ b/prompts/zh-CN/tool_executor.prompt @@ -0,0 +1,11 @@ +你是一个专门执行工具的助手。你的名字是{bot_name}。现在是{time_now}。 +群里正在进行的聊天内容: +{chat_history} + +现在,{sender}发送了内容:{target_message},你想要回复ta。 +请仔细分析聊天内容,考虑以下几点: +1. 内容中是否包含需要查询信息的问题 +2. 是否有明确的工具使用指令 +你可以选择多个动作 + +If you need to use tools, please directly call the corresponding tool function. If you do not need to use any tool, simply output "No tool needed". \ No newline at end of file diff --git a/pytests/prompt_test/test_prompt_i18n.py b/pytests/prompt_test/test_prompt_i18n.py new file mode 100644 index 00000000..76b2fcdb --- /dev/null +++ b/pytests/prompt_test/test_prompt_i18n.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from src.common.i18n import set_locale +from src.common.prompt_i18n import clear_prompt_cache, load_prompt, list_prompt_templates +from src.prompt.prompt_manager import PromptManager + + +@pytest.fixture(autouse=True) +def clear_prompt_i18n_cache() -> None: + set_locale("zh-CN") + clear_prompt_cache() + yield + clear_prompt_cache() + set_locale("zh-CN") + + +def write_prompt(prompt_dir: Path, locale: str | None, name: str, content: str) -> None: + base_dir = prompt_dir if locale is None else prompt_dir / locale + base_dir.mkdir(parents=True, exist_ok=True) + (base_dir / f"{name}.prompt").write_text(content, encoding="utf-8") + + +def test_load_prompt_prefers_requested_locale(tmp_path: Path) -> None: + prompts_root = tmp_path / "prompts" + write_prompt(prompts_root, "zh-CN", "replyer", "你好,{user_name}") + write_prompt(prompts_root, "en-US", "replyer", "Hello, {user_name}") + + rendered = load_prompt("replyer", locale="en-US", prompts_root=prompts_root, user_name="Mai") + + assert rendered == "Hello, Mai" + + +def test_load_prompt_falls_back_to_default_locale(tmp_path: Path) -> None: + prompts_root = tmp_path / "prompts" + write_prompt(prompts_root, "zh-CN", "replyer", "你好,{user_name}") + + rendered = load_prompt("replyer", locale="en-US", prompts_root=prompts_root, user_name="Mai") + + assert rendered == "你好,Mai" + + +def test_load_prompt_falls_back_to_legacy_root(tmp_path: Path) -> None: + prompts_root = tmp_path / "prompts" + write_prompt(prompts_root, None, "replyer", "Legacy {user_name}") + + rendered = load_prompt("replyer", locale="en-US", prompts_root=prompts_root, user_name="Mai") + + assert rendered == "Legacy Mai" + + +def test_load_prompt_with_category_falls_back_to_legacy_root(tmp_path: Path) -> None: + prompts_root = tmp_path / "prompts" + write_prompt(prompts_root, None, "replyer", "Legacy {user_name}") + + rendered = load_prompt("replyer", locale="en-US", category="chat", prompts_root=prompts_root, user_name="Mai") + + assert rendered == "Legacy Mai" + + +def test_load_prompt_strict_mode_raises_on_missing_placeholder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + prompts_root = tmp_path / "prompts" + write_prompt(prompts_root, "zh-CN", "replyer", "你好,{user_name},现在是 {current_time}") + monkeypatch.setenv("MAIBOT_PROMPT_I18N_STRICT", "1") + + with pytest.raises(KeyError) as exc_info: + load_prompt("replyer", locale="zh-CN", prompts_root=prompts_root, user_name="Mai") + + assert "current_time" in str(exc_info.value) + + +def test_load_prompt_rejects_path_traversal(tmp_path: Path) -> None: + prompts_root = tmp_path / "prompts" + write_prompt(prompts_root, "zh-CN", "replyer", "你好") + + with pytest.raises(ValueError): + load_prompt("../replyer", locale="zh-CN", prompts_root=prompts_root) + + +def test_list_prompt_templates_prefers_locale_specific_files(tmp_path: Path) -> None: + prompts_root = tmp_path / "prompts" + write_prompt(prompts_root, None, "replyer", "Legacy") + write_prompt(prompts_root, "zh-CN", "replyer", "中文") + write_prompt(prompts_root, "en-US", "replyer", "English") + set_locale("en-US") + + prompt_templates = list_prompt_templates(prompts_root=prompts_root) + + assert prompt_templates["replyer"].read_text(encoding="utf-8") == "English" + + +def test_list_prompt_templates_reports_duplicate_name_with_custom_root(tmp_path: Path) -> None: + prompts_root = tmp_path / "prompts" + first_dir = prompts_root / "zh-CN" / "chat" + second_dir = prompts_root / "zh-CN" / "system" + first_dir.mkdir(parents=True, exist_ok=True) + second_dir.mkdir(parents=True, exist_ok=True) + (first_dir / "replyer.prompt").write_text("chat", encoding="utf-8") + (second_dir / "replyer.prompt").write_text("system", encoding="utf-8") + + with pytest.raises(ValueError) as exc_info: + list_prompt_templates(prompts_root=prompts_root) + + assert "zh-CN/chat/replyer.prompt" in str(exc_info.value) + assert "zh-CN/system/replyer.prompt" in str(exc_info.value) + + +def test_prompt_manager_load_prompts_prefers_locale_dir( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + prompts_root = tmp_path / "prompts" + custom_prompts_root = tmp_path / "data" / "custom_prompts" + custom_prompts_root.mkdir(parents=True, exist_ok=True) + write_prompt(prompts_root, "zh-CN", "replyer", "中文模板") + write_prompt(prompts_root, "en-US", "replyer", "English template") + set_locale("en-US") + + monkeypatch.setattr("src.prompt.prompt_manager.PROMPTS_DIR", prompts_root, raising=False) + monkeypatch.setattr("src.prompt.prompt_manager.CUSTOM_PROMPTS_DIR", custom_prompts_root, raising=False) + monkeypatch.setattr("src.prompt.prompt_manager.SUFFIX_PROMPT", ".prompt", raising=False) + + manager = PromptManager() + manager.load_prompts() + + assert manager.get_prompt("replyer").template == "English template" diff --git a/scripts/i18n_validate.py b/scripts/i18n_validate.py index 632ce12c..d2c654d3 100644 --- a/scripts/i18n_validate.py +++ b/scripts/i18n_validate.py @@ -16,6 +16,11 @@ from src.common.i18n.loaders import ( # noqa: E402 get_locales_root, load_locale_catalog, ) +from src.common.prompt_i18n import ( # noqa: E402 + PROMPT_EXTENSIONS, + extract_prompt_placeholders, + get_prompts_root, +) FORMATTER = Formatter() @@ -63,7 +68,7 @@ def validate_translation_pair( errors.append(f"[{locale}] key '{key}' 的 plural category '{category}' 占位符集合与 source 不一致") -def validate_locales(locales_root: Path | None = None) -> list[str]: +def validate_json_locales(locales_root: Path | None = None) -> list[str]: resolved_locales_root = get_locales_root(locales_root) locales = discover_locales(resolved_locales_root) errors: list[str] = [] @@ -103,15 +108,99 @@ def validate_locales(locales_root: Path | None = None) -> list[str]: return errors +def discover_prompt_locales(prompts_root: Path | None = None) -> list[str]: + resolved_prompts_root = get_prompts_root(prompts_root) + if not resolved_prompts_root.exists(): + return [] + + locale_names = [path.name for path in resolved_prompts_root.iterdir() if path.is_dir()] + return sorted(locale_names) + + +def iter_prompt_files(locale_dir: Path) -> list[Path]: + prompt_files: list[Path] = [] + for extension in PROMPT_EXTENSIONS: + prompt_files.extend(path for path in locale_dir.rglob(f"*{extension}") if path.is_file()) + return sorted(set(prompt_files)) + + +def validate_prompt_templates(prompts_root: Path | None = None) -> tuple[list[str], list[str]]: + resolved_prompts_root = get_prompts_root(prompts_root) + prompt_locales = discover_prompt_locales(resolved_prompts_root) + known_locales = [locale for locale in discover_locales(get_locales_root()) if locale != DEFAULT_LOCALE] + errors: list[str] = [] + warnings: list[str] = [] + + if DEFAULT_LOCALE not in prompt_locales: + errors.append(f"缺少默认 Prompt locale 目录: {DEFAULT_LOCALE}") + return errors, warnings + + source_dir = resolved_prompts_root / DEFAULT_LOCALE + source_files = {path.relative_to(source_dir): path for path in iter_prompt_files(source_dir)} + + for locale in known_locales: + locale_dir = resolved_prompts_root / locale + if not locale_dir.exists(): + warnings.append(f"[prompt:{locale}] 缺少 locale 目录,运行时将回退到 {DEFAULT_LOCALE}") + continue + + locale_files = {path.relative_to(locale_dir): path for path in iter_prompt_files(locale_dir)} + source_relative_paths = set(source_files.keys()) + locale_relative_paths = set(locale_files.keys()) + + for relative_path in sorted(source_relative_paths - locale_relative_paths): + warnings.append(f"[prompt:{locale}] 缺少模板: {relative_path.as_posix()},运行时将回退到 {DEFAULT_LOCALE}") + + for relative_path in sorted(locale_relative_paths - source_relative_paths): + warnings.append(f"[prompt:{locale}] 存在额外模板: {relative_path.as_posix()}") + + for relative_path in sorted(source_relative_paths & locale_relative_paths): + source_text = source_files[relative_path].read_text(encoding="utf-8") + locale_text = locale_files[relative_path].read_text(encoding="utf-8") + + source_placeholders = extract_prompt_placeholders(source_text) + locale_placeholders = extract_prompt_placeholders(locale_text) + if source_placeholders != locale_placeholders: + errors.append( + "[prompt:{locale}] 模板 '{path}' 的占位符集合与 source 不一致:" + "source={source_placeholders}, target={target_placeholders}".format( + locale=locale, + path=relative_path.as_posix(), + source_placeholders=sorted(source_placeholders), + target_placeholders=sorted(locale_placeholders), + ) + ) + + if source_text == locale_text: + warnings.append(f"[prompt:{locale}] 模板 '{relative_path.as_posix()}' 与 source 完全相同,可能尚未翻译") + + return errors, warnings + + def main() -> int: - errors = validate_locales() + errors = validate_json_locales() + prompt_errors, prompt_warnings = validate_prompt_templates() + errors.extend(prompt_errors) + if errors: print("i18n validation failed:") for error in errors: print(f" - {error}") + if prompt_warnings: + print(f"warnings ({len(prompt_warnings)}):") + for warning in prompt_warnings[:10]: + print(f" - {warning}") + if len(prompt_warnings) > 10: + print(f" - ... 另外还有 {len(prompt_warnings) - 10} 条 warning") return 1 print("i18n validation passed.") + if prompt_warnings: + print(f"warnings ({len(prompt_warnings)}):") + for warning in prompt_warnings[:10]: + print(f" - {warning}") + if len(prompt_warnings) > 10: + print(f" - ... 另外还有 {len(prompt_warnings) - 10} 条 warning") return 0 diff --git a/src/common/prompt_i18n.py b/src/common/prompt_i18n.py new file mode 100644 index 00000000..7d0999df --- /dev/null +++ b/src/common/prompt_i18n.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from pathlib import Path +from string import Formatter + +import logging +import os +import re +import threading + +from .i18n import get_locale, t +from .i18n.loaders import DEFAULT_LOCALE, normalize_locale + +logger = logging.getLogger("maibot.prompt_i18n") + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +PROMPTS_ROOT = (PROJECT_ROOT / "prompts").resolve() +PROMPT_EXTENSIONS = (".prompt", ".txt") +FORMATTER = Formatter() +SAFE_SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$") +STRICT_ENV_KEYS = ("MAIBOT_PROMPT_I18N_STRICT", "MAIBOT_I18N_STRICT") + +_prompt_cache: dict[Path, str] = {} +_cache_lock = threading.RLock() + + +def extract_prompt_placeholders(template: str) -> set[str]: + placeholders: set[str] = set() + for _, field_name, _, _ in FORMATTER.parse(template): + if not field_name: + continue + placeholders.add(field_name.split(".", maxsplit=1)[0].split("[", maxsplit=1)[0]) + return placeholders + + +def get_prompts_root(prompts_root: Path | None = None) -> Path: + return (prompts_root or PROMPTS_ROOT).resolve() + + +def normalize_prompt_name(name: str) -> str: + candidate_name = name.strip() + for suffix in PROMPT_EXTENSIONS: + if candidate_name.endswith(suffix): + candidate_name = candidate_name[: -len(suffix)] + break + + if candidate_name in {".", ".."} or not candidate_name or not SAFE_SEGMENT_PATTERN.fullmatch(candidate_name): + raise ValueError(t("prompt.invalid_name", name=name)) + return candidate_name + + +def normalize_prompt_category(category: str | None) -> str | None: + if category is None: + return None + + category_parts = [part for part in category.strip().split("/") if part] + if not category_parts: + raise ValueError(t("prompt.invalid_category", category=category)) + + for part in category_parts: + if part in {".", ".."} or not SAFE_SEGMENT_PATTERN.fullmatch(part): + raise ValueError(t("prompt.invalid_category", category=category)) + return "/".join(category_parts) + + +def is_strict_prompt_i18n_mode() -> bool: + if os.getenv("PYTEST_CURRENT_TEST"): + return True + + return any(os.getenv(env_key, "").strip().lower() in {"1", "true", "yes", "on"} for env_key in STRICT_ENV_KEYS) + + +def _supported_prompt_files(directory: Path) -> list[Path]: + matched_files: list[Path] = [] + for suffix in PROMPT_EXTENSIONS: + matched_files.extend(path for path in directory.rglob(f"*{suffix}") if path.is_file()) + return sorted(set(matched_files)) + + +def _supported_prompt_files_non_recursive(directory: Path) -> list[Path]: + matched_files: list[Path] = [] + for suffix in PROMPT_EXTENSIONS: + matched_files.extend(path for path in directory.glob(f"*{suffix}") if path.is_file()) + return sorted(set(matched_files)) + + +def _scan_prompt_directory(directory: Path, prompts_root: Path) -> dict[str, Path]: + prompt_paths: dict[str, Path] = {} + if not directory.exists(): + return prompt_paths + + for prompt_path in _supported_prompt_files(directory): + prompt_name = prompt_path.stem + if prompt_name in prompt_paths: + raise ValueError( + t( + "prompt.duplicate_template_name", + name=prompt_name, + path_a=prompt_paths[prompt_name].relative_to(prompts_root), + path_b=prompt_path.relative_to(prompts_root), + ) + ) + prompt_paths[prompt_name] = prompt_path + return prompt_paths + + +def _scan_legacy_prompt_directory(directory: Path) -> dict[str, Path]: + prompt_paths: dict[str, Path] = {} + if not directory.exists(): + return prompt_paths + + for prompt_path in _supported_prompt_files_non_recursive(directory): + prompt_name = prompt_path.stem + if prompt_name in prompt_paths: + raise ValueError( + t( + "prompt.duplicate_template_name", + name=prompt_name, + path_a=prompt_paths[prompt_name].relative_to(get_prompts_root(directory)), + path_b=prompt_path.relative_to(get_prompts_root(directory)), + ) + ) + prompt_paths[prompt_name] = prompt_path + return prompt_paths + + +def list_prompt_templates(locale: str | None = None, prompts_root: Path | None = None) -> dict[str, Path]: + resolved_prompts_root = get_prompts_root(prompts_root) + requested_locale = normalize_locale(locale or get_locale()) + + prompt_paths = _scan_legacy_prompt_directory(resolved_prompts_root) + prompt_paths.update(_scan_prompt_directory(resolved_prompts_root / DEFAULT_LOCALE, resolved_prompts_root)) + + if requested_locale != DEFAULT_LOCALE: + prompt_paths.update(_scan_prompt_directory(resolved_prompts_root / requested_locale, resolved_prompts_root)) + + return prompt_paths + + +def resolve_prompt_path(name: str, locale: str | None = None, category: str | None = None, prompts_root: Path | None = None) -> Path: + resolved_prompts_root = get_prompts_root(prompts_root) + normalized_name = normalize_prompt_name(name) + normalized_category = normalize_prompt_category(category) + requested_locale = normalize_locale(locale or get_locale()) + + locale_candidates: list[str | None] = [requested_locale] + if requested_locale != DEFAULT_LOCALE: + locale_candidates.append(DEFAULT_LOCALE) + locale_candidates.append(None) + + if normalized_category is not None: + for locale_candidate in locale_candidates: + base_dir = resolved_prompts_root if locale_candidate is None else resolved_prompts_root / locale_candidate + for suffix in PROMPT_EXTENSIONS: + candidate_paths = [(base_dir / normalized_category / f"{normalized_name}{suffix}").resolve()] + # 允许带 category 的调用在旧版平铺目录或未迁移完的 locale 目录中继续工作。 + candidate_paths.append((base_dir / f"{normalized_name}{suffix}").resolve()) + for candidate_path in candidate_paths: + if candidate_path.is_file(): + return candidate_path + else: + prompt_paths = list_prompt_templates(locale=requested_locale, prompts_root=resolved_prompts_root) + if normalized_name in prompt_paths: + return prompt_paths[normalized_name] + + raise FileNotFoundError(t("prompt.template_not_found", locale=requested_locale, name=normalized_name)) + + +def load_prompt( + name: str, + locale: str | None = None, + category: str | None = None, + prompts_root: Path | None = None, + **kwargs: object, +) -> str: + prompt_path = resolve_prompt_path(name=name, locale=locale, category=category, prompts_root=prompts_root) + with _cache_lock: + template = _prompt_cache.get(prompt_path) + if template is None: + with open(prompt_path, "r", encoding="utf-8") as prompt_file: + template = prompt_file.read() + _prompt_cache[prompt_path] = template + + if not kwargs: + return template + + try: + return template.format(**kwargs) + except KeyError as exc: + missing_placeholder = exc.args[0] + error = KeyError( + t( + "prompt.missing_placeholder", + name=normalize_prompt_name(name), + placeholder=missing_placeholder, + ) + ) + if is_strict_prompt_i18n_mode(): + raise error from exc + logger.error("%s", error) + return template + except Exception as exc: + logger.error(t("prompt.format_failed", name=normalize_prompt_name(name), error=exc)) + if is_strict_prompt_i18n_mode(): + raise + return template + + +def clear_prompt_cache() -> None: + with _cache_lock: + _prompt_cache.clear() diff --git a/src/prompt/prompt_manager.py b/src/prompt/prompt_manager.py index 45ef7188..aa4402c6 100644 --- a/src/prompt/prompt_manager.py +++ b/src/prompt/prompt_manager.py @@ -1,9 +1,11 @@ -from collections.abc import Callable, Coroutine -from typing import Any, Optional -from string import Formatter from pathlib import Path +from string import Formatter +from typing import Any, Optional + +from collections.abc import Callable, Coroutine import inspect +from src.common.prompt_i18n import list_prompt_templates, load_prompt from src.common.logger import get_logger @@ -257,22 +259,26 @@ class PromptManager: Raises: Exception: 如果在加载过程中出现任何文件操作错误则引发该异常 """ - for prompt_file in PROMPTS_DIR.glob(f"*{SUFFIX_PROMPT}"): + prompt_files = list_prompt_templates(prompts_root=PROMPTS_DIR) + for prompt_name, prompt_file in prompt_files.items(): try: prompt_to_load = prompt_file need_save = False - if (CUSTOM_PROMPTS_DIR / prompt_file.name).exists(): + custom_prompt_path = CUSTOM_PROMPTS_DIR / f"{prompt_name}{SUFFIX_PROMPT}" + if custom_prompt_path.exists(): # 优先加载自定义目录下的 Prompt 文件 - prompt_to_load = CUSTOM_PROMPTS_DIR / prompt_file.name + prompt_to_load = custom_prompt_path need_save = True - with open(prompt_to_load, "r", encoding="utf-8") as f: - template = f.read() - self.add_prompt(Prompt(prompt_name=prompt_to_load.stem, template=template), need_save=need_save) + with open(prompt_to_load, "r", encoding="utf-8") as f: + template = f.read() + else: + template = load_prompt(prompt_name, prompts_root=PROMPTS_DIR) + self.add_prompt(Prompt(prompt_name=prompt_name, template=template), need_save=need_save) except Exception as e: logger.error(f"加载 Prompt 文件 '{prompt_file}' 时出错,错误信息: {e}") raise e for prompt_file in CUSTOM_PROMPTS_DIR.glob(f"*{SUFFIX_PROMPT}"): - if (PROMPTS_DIR / prompt_file.name).exists(): + if prompt_file.stem in prompt_files: continue # 已经加载过了,跳过 try: with open(prompt_file, "r", encoding="utf-8") as f: