1
This commit is contained in:
86
server-rs/crates/api-server/src/prompt/puzzle/draft.rs
Normal file
86
server-rs/crates/api-server/src/prompt/puzzle/draft.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
/// 拼图作品草稿生成动作的提示词主源。
|
||||
///
|
||||
/// 拼图结果页草稿本体仍由 SpacetimeDB reducer 按表单/锚点确定性编译;
|
||||
/// 这里收口 api-server 在生成草稿前后需要写入 reducer 的表单 seed 文本,
|
||||
/// 以及草稿首图生成时的 prompt 来源选择,避免业务路由直接拼提示词文本。
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct PuzzleFormSeedPromptParts<'a> {
|
||||
pub(crate) title: Option<&'a str>,
|
||||
pub(crate) work_description: Option<&'a str>,
|
||||
pub(crate) picture_description: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// 将填表式拼图输入编译成 SpacetimeDB 可恢复的表单 seed prompt。
|
||||
pub(crate) fn build_puzzle_form_seed_prompt(parts: PuzzleFormSeedPromptParts<'_>) -> String {
|
||||
[
|
||||
("作品名称", normalize_prompt_part(parts.title)),
|
||||
("作品描述", normalize_prompt_part(parts.work_description)),
|
||||
("画面描述", normalize_prompt_part(parts.picture_description)),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(label, value)| value.map(|value| format!("{label}:{value}")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// 生成作品草稿时,首图 prompt 优先使用玩家当前表单里的画面描述。
|
||||
pub(crate) fn resolve_puzzle_draft_cover_prompt(
|
||||
explicit_prompt: Option<&str>,
|
||||
level_picture_description: &str,
|
||||
draft_summary: &str,
|
||||
) -> String {
|
||||
normalize_prompt_part(explicit_prompt)
|
||||
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
|
||||
.or_else(|| normalize_prompt_part(Some(draft_summary)))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 结果页单关重新生成时,优先使用面板当前编辑态 prompt,再回退关卡画面描述。
|
||||
pub(crate) fn resolve_puzzle_level_image_prompt(
|
||||
explicit_prompt: Option<&str>,
|
||||
level_picture_description: &str,
|
||||
) -> String {
|
||||
normalize_prompt_part(explicit_prompt)
|
||||
.or_else(|| normalize_prompt_part(Some(level_picture_description)))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_prompt_part(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn form_seed_prompt_keeps_only_user_visible_fields() {
|
||||
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||||
title: Some(" 暖灯猫街 "),
|
||||
work_description: Some("雨夜礼物拼图"),
|
||||
picture_description: Some("猫咪在灯牌下回头"),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_cover_prompt_prefers_current_picture_description() {
|
||||
let prompt =
|
||||
resolve_puzzle_draft_cover_prompt(Some(" 当前表单画面 "), "旧关卡画面", "作品简介");
|
||||
|
||||
assert_eq!(prompt, "当前表单画面");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_image_prompt_falls_back_to_level_description() {
|
||||
let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述");
|
||||
|
||||
assert_eq!(prompt, "关卡画面描述");
|
||||
}
|
||||
}
|
||||
@@ -38,17 +38,15 @@ pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> Strin
|
||||
image_prompt
|
||||
}
|
||||
|
||||
fn build_puzzle_image_prompt_text(level_name: &str, prompt: &str) -> String {
|
||||
fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"请生成一张高清插画。",
|
||||
"关卡名:{level_name}。",
|
||||
"画面主体:{prompt}。",
|
||||
"画面要求:1:1 正方形拼图关卡,适配 3x3 或 4x4 拼图切块,",
|
||||
"画面要求:1:1",
|
||||
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
|
||||
"避免文字、水印、边框和 UI 元素。"
|
||||
),
|
||||
level_name = level_name,
|
||||
prompt = prompt,
|
||||
)
|
||||
}
|
||||
@@ -78,10 +76,9 @@ mod tests {
|
||||
fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() {
|
||||
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
|
||||
|
||||
assert!(prompt.contains("雨夜神庙"));
|
||||
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("1:1"));
|
||||
assert!(prompt.contains("主体要清晰集中"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
@@ -93,8 +90,8 @@ mod tests {
|
||||
let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str());
|
||||
|
||||
assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS);
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("1:1"));
|
||||
assert!(prompt.contains("主体要清晰集中"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod agent_chat;
|
||||
pub(crate) mod draft;
|
||||
pub(crate) mod image;
|
||||
|
||||
@@ -240,7 +240,10 @@ JSON 结构:
|
||||
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
|
||||
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。
|
||||
- 非敌对聊天 shouldEndChat 必须为 false。
|
||||
- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
|
||||
- 敌对聊天可以随时 shouldEndChat=true。
|
||||
- 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 shouldEndChat=true。
|
||||
- 敌对 NPC 已聊天轮次达到 4 轮或以上时,本轮结束后会超过 4 轮,应倾向立即 shouldEndChat=true。
|
||||
- shouldEndChat=true 时 terminationReason 使用 hostile_breakoff,suggestions 与 functionSuggestions 可以为空。"#;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct NpcChatTurnPromptInput<'a> {
|
||||
@@ -394,6 +397,19 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_hostile_model_chat {
|
||||
Some("如果玩家刚才的话被 NPC 感知为负面发言,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,本轮回复应倾向写成最后通牒、驱逐前警告或战斗前狠话。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_hostile_model_chat && chatted_count >= 4.0 {
|
||||
Some(format!(
|
||||
"敌对聊天已持续 {} 轮,本轮结束后会超过 4 轮;回复应明显倾向立即收束,像开战前最后一句狠话,而不是继续闲聊。",
|
||||
format_prompt_number(chatted_count)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
|
||||
} else {
|
||||
@@ -474,6 +490,9 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
|
||||
.and_then(|record| read_string(record.get("terminationReason")))
|
||||
.as_deref()
|
||||
== Some("player_exit");
|
||||
let chatted_count = as_record(payload.npc_state)
|
||||
.and_then(|record| read_number(record.get("chattedCount")))
|
||||
.unwrap_or(0.0);
|
||||
let function_options_block = chat_directive
|
||||
.and_then(|record| record.get("functionOptions"))
|
||||
.map(describe_function_options)
|
||||
@@ -498,6 +517,14 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt(
|
||||
} else {
|
||||
Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string())
|
||||
},
|
||||
if is_hostile_model_chat {
|
||||
Some(format!(
|
||||
"敌对聊天判定:已聊天轮次为 {}。若玩家刚才的话可被 NPC 感知为负面发言,或已聊天轮次达到 4 轮及以上,本轮应倾向 shouldEndChat=true,并使用 terminationReason=hostile_breakoff。",
|
||||
format_prompt_number(chatted_count)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_player_exit_turn {
|
||||
Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string())
|
||||
} else {
|
||||
@@ -526,6 +553,20 @@ pub(crate) fn build_deterministic_npc_reply(
|
||||
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
|
||||
}
|
||||
|
||||
pub(crate) fn build_deterministic_hostile_breakoff_reply(
|
||||
npc_name: &str,
|
||||
player_message: &str,
|
||||
) -> String {
|
||||
// 中文注释:当模型不可用而敌对聊天必须中止时,兜底文案也保持“战斗前狠话”的语气。
|
||||
let player_signal = player_message.trim();
|
||||
if player_signal.is_empty() {
|
||||
return format!("{npc_name}冷声说道:“话已经够多了。再往前一步,就别指望还能全身而退。”");
|
||||
}
|
||||
format!(
|
||||
"{npc_name}冷声说道:“{player_signal}?话已经够多了。再往前一步,就别指望还能全身而退。”"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_chat_reply_fallback(
|
||||
target_character: &Value,
|
||||
player_message: &str,
|
||||
@@ -1066,3 +1107,55 @@ fn format_prompt_number(value: f64) -> String {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hostile_prompt_input(npc_state: Value) -> NpcChatTurnPromptInput<'static> {
|
||||
NpcChatTurnPromptInput {
|
||||
world_type: "CUSTOM",
|
||||
character: Box::leak(Box::new(Value::Null)),
|
||||
encounter: Box::leak(Box::new(Value::Null)),
|
||||
monsters: &[],
|
||||
history: &[],
|
||||
context: Box::leak(Box::new(Value::Null)),
|
||||
conversation_history: &[],
|
||||
dialogue: &[],
|
||||
combat_context: None,
|
||||
player_message: "少废话,让开。",
|
||||
npc_state: Box::leak(Box::new(npc_state)),
|
||||
npc_initiates_conversation: false,
|
||||
chat_directive: Some(Box::leak(Box::new(json!({
|
||||
"terminationMode": "hostile_model",
|
||||
"isHostileChat": true,
|
||||
})))),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_reply_prompt_mentions_final_threat_after_four_turns() {
|
||||
let input = hostile_prompt_input(json!({
|
||||
"affinity": -12,
|
||||
"chattedCount": 4,
|
||||
}));
|
||||
let prompt = build_npc_chat_turn_reply_prompt(&input);
|
||||
|
||||
assert!(prompt.contains("已聊天轮次:4"));
|
||||
assert!(prompt.contains("战斗前狠话"));
|
||||
assert!(prompt.contains("本轮结束后会超过 4 轮"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_suggestion_prompt_mentions_should_end_chat_signals() {
|
||||
let input = hostile_prompt_input(json!({
|
||||
"affinity": -12,
|
||||
"chattedCount": 4,
|
||||
}));
|
||||
let prompt = build_npc_chat_turn_suggestion_prompt(&input, "再往前一步,就别想回头。");
|
||||
|
||||
assert!(prompt.contains("shouldEndChat=true"));
|
||||
assert!(prompt.contains("terminationReason=hostile_breakoff"));
|
||||
assert!(prompt.contains("已聊天轮次为 4"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user