Files
Genarrative/server-rs/crates/api-server/src/runtime_chat_prompt.rs

550 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde_json::Value;
pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#;
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
只输出纯文本,共 3 行,每行 1 条。
不要加编号、项目符号、Markdown、JSON 或额外说明。
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。"#;
#[derive(Debug)]
pub(crate) struct NpcChatTurnPromptInput<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: &'a [Value],
pub history: &'a [Value],
pub context: &'a Value,
pub conversation_history: &'a [Value],
pub dialogue: &'a [Value],
pub combat_context: Option<&'a Value>,
pub player_message: &'a str,
pub npc_state: &'a Value,
pub npc_initiates_conversation: bool,
pub chat_directive: Option<&'a Value>,
}
pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
let context = as_record(payload.context);
let npc_state = as_record(payload.npc_state);
let chat_directive = payload.chat_directive.and_then(as_record);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let opening_camp_background =
context.and_then(|record| read_string(record.get("openingCampBackground")));
let opening_camp_dialogue =
context.and_then(|record| read_string(record.get("openingCampDialogue")));
let allowed_topics = context
.and_then(|record| record.get("encounterAllowedTopics"))
.map(read_string_array)
.unwrap_or_default();
let blocked_topics = context
.and_then(|record| record.get("encounterBlockedTopics"))
.map(read_string_array)
.unwrap_or_default();
let is_first_meaningful_contact = context
.and_then(|record| read_bool(record.get("isFirstMeaningfulContact")))
.unwrap_or(false);
let affinity = npc_state
.and_then(|record| read_number(record.get("affinity")))
.unwrap_or(0.0);
let chatted_count = npc_state
.and_then(|record| read_number(record.get("chattedCount")))
.unwrap_or(0.0);
let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason")));
let turn_limit = chat_directive
.and_then(|record| read_number(record.get("turnLimit")))
.unwrap_or(0.0)
.max(0.0);
let remaining_turns = chat_directive
.and_then(|record| read_number(record.get("remainingTurns")))
.unwrap_or(0.0)
.max(0.0);
let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode")));
let is_limited_negative_affinity_chat =
limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0;
let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close")
|| chat_directive
.and_then(|record| read_bool(record.get("forceExitAfterTurn")))
.unwrap_or(false);
let has_npc_reply_in_history = conversation_history.iter().any(|item| {
as_record(item)
.and_then(|turn| read_string(turn.get("speaker")))
.is_some_and(|speaker| speaker == "npc")
});
let is_first_npc_spoken_turn =
is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0;
let first_contact_relation_stance = describe_first_contact_relation_stance(
context.and_then(|record| record.get("firstContactRelationStance")),
);
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
opening_camp_background.map(|text| format!("营地开场背景:{text}")),
opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")),
Some(format!("当前关系值:{}", format_prompt_number(affinity))),
Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))),
if is_first_npc_spoken_turn {
Some(format!(
"当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。",
encounter.npc_name
))
} else {
None
},
if is_first_npc_spoken_turn {
Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string())
} else {
None
},
if is_first_npc_spoken_turn {
Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some(format!(
"当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。",
encounter.npc_name
))
} else {
None
},
if allowed_topics.is_empty() {
None
} else {
Some(format!("当前更适合先谈:{}", allowed_topics.join("")))
},
if blocked_topics.is_empty() {
None
} else {
Some(format!("当前避免直接说破:{}", blocked_topics.join("")))
},
if is_limited_negative_affinity_chat {
Some(format!(
"当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。",
format_prompt_number(turn_limit)
))
} else {
None
},
if is_limited_negative_affinity_chat {
Some(format!(
"在你回复完这一轮之后,还剩 {} 轮可以继续聊。",
format_prompt_number(remaining_turns)
))
} else {
None
},
if is_limited_negative_affinity_chat && !is_foreshadow_close_turn {
Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message.trim()))
},
if payload.npc_initiates_conversation {
Some(format!(
"现在请只写 {} 主动开口时会说的话。",
encounter.npc_name
))
} else {
Some(format!(
"现在请只写 {} 这一轮会回复玩家的话。",
encounter.npc_name
))
},
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
pub(crate) fn build_npc_chat_turn_suggestion_prompt(
payload: &NpcChatTurnPromptInput<'_>,
npc_reply: &str,
) -> String {
let encounter = describe_encounter(payload.encounter);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
Some(format!("玩家刚刚说:{}", payload.player_message)),
Some(format!("NPC 刚刚回复:{npc_reply}")),
Some("请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。".to_string()),
Some("每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。".to_string()),
Some("每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。".to_string()),
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
[
format!("世界:{}", describe_world(payload.world_type)),
describe_scene_context(payload.context),
describe_character("玩家 / ", payload.character),
encounter.block,
describe_monsters(payload.monsters),
describe_story_history(payload.history),
]
.into_iter()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
struct EncounterDescription {
npc_name: String,
block: String,
}
fn describe_encounter(encounter: &Value) -> EncounterDescription {
let record = as_record(encounter);
let npc_name = record
.and_then(|item| read_string(item.get("npcName")))
.unwrap_or_else(|| "眼前角色".to_string());
let context_text = record
.and_then(|item| read_string(item.get("context")))
.or_else(|| record.and_then(|item| read_string(item.get("npcDescription"))))
.unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string());
EncounterDescription {
npc_name: npc_name.clone(),
block: format!("当前对象:{npc_name}\n对象背景:{context_text}"),
}
}
fn describe_first_contact_relation_stance(value: Option<&Value>) -> String {
match value.and_then(|item| item.as_str()).map(str::trim) {
Some("guarded") => "戒备试探".to_string(),
Some("neutral") => "正常交流但仍不熟".to_string(),
Some("cooperative") => "已有善意,先确认合作节奏".to_string(),
Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(),
_ => "第一次真正接触".to_string(),
}
}
fn describe_world(world_type: &str) -> String {
match world_type {
"WUXIA" => "边城模板".to_string(),
"XIANXIA" => "灵潮模板".to_string(),
"CUSTOM" => "自定义世界".to_string(),
value if !value.trim().is_empty() => value.to_string(),
_ => "未知世界".to_string(),
}
}
fn describe_stats(label: &str, record: Option<&serde_json::Map<String, Value>>) -> String {
let hp = record
.and_then(|item| read_number(item.get("hp")))
.unwrap_or(0.0);
let max_hp = record
.and_then(|item| read_number(item.get("maxHp")))
.unwrap_or(hp)
.max(1.0);
let mana = record
.and_then(|item| read_number(item.get("mana")))
.unwrap_or(0.0);
let max_mana = record
.and_then(|item| read_number(item.get("maxMana")))
.unwrap_or(mana)
.max(1.0);
format!(
"{label}生命 {}/{},灵力 {}/{}",
format_prompt_number(hp),
format_prompt_number(max_hp),
format_prompt_number(mana),
format_prompt_number(max_mana)
)
}
fn describe_character(label: &str, value: &Value) -> String {
let record = as_record(value);
let name = record
.and_then(|item| read_string(item.get("name")))
.unwrap_or_else(|| "未知角色".to_string());
let title = record
.and_then(|item| read_string(item.get("title")))
.unwrap_or_else(|| "未知称号".to_string());
let description = record
.and_then(|item| read_string(item.get("description")))
.unwrap_or_else(|| "暂无额外描述".to_string());
let personality = record
.and_then(|item| read_string(item.get("personality")))
.unwrap_or_else(|| "性格信息未显式提供".to_string());
[
format!("{label}姓名:{name}"),
format!("{label}称号:{title}"),
format!("{label}描述:{description}"),
format!("{label}性格:{personality}"),
]
.join("\n")
}
fn describe_story_history(history: &[Value]) -> String {
if history.is_empty() {
return "近期剧情:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text"))))
.collect::<Vec<_>>();
if lines.is_empty() {
"近期剧情:暂无。".to_string()
} else {
let mut result = vec!["近期剧情:".to_string()];
result.extend(lines.into_iter().map(|line| format!("- {line}")));
result.join("\n")
}
}
fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String {
if history.is_empty() {
return "当前聊天记录:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(10)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| {
let record = as_record(item)?;
let speaker = read_string(record.get("speaker"));
let speaker_name = read_string(record.get("speakerName"));
let text = read_string(record.get("text"))?;
match speaker.as_deref() {
Some("player") => Some(format!("- 玩家:{text}")),
Some("npc") => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| npc_name.to_string())
)),
Some("system") => Some(format!("- 系统提示:{text}")),
_ => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| "同伴".to_string())
)),
}
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前聊天记录:暂无。".to_string()
} else {
let mut result = vec!["当前聊天记录:".to_string()];
result.extend(lines);
result.join("\n")
}
}
fn describe_npc_combat_context(combat_context: &Value) -> Option<String> {
let record = as_record(combat_context)?;
let summary = read_string(record.get("summary"));
let battle_outcome = read_string(record.get("battleOutcome"));
let log_lines = record
.get("logLines")
.map(read_string_array)
.unwrap_or_default()
.into_iter()
.take(6)
.collect::<Vec<_>>();
if summary.is_none() && log_lines.is_empty() {
return None;
}
let outcome_text = match battle_outcome.as_deref() {
Some("spar_complete") => Some("切磋刚刚结束。".to_string()),
Some("victory") => Some("战斗刚刚分出胜负。".to_string()),
_ => None,
};
let mut lines = vec!["刚刚结束的交锋:".to_string()];
if let Some(text) = outcome_text {
lines.push(text);
}
if let Some(text) = summary {
lines.push(format!("- 结果摘要:{text}"));
}
if !log_lines.is_empty() {
lines.push("- 战斗日志:".to_string());
lines.extend(log_lines.into_iter().map(|line| format!(" - {line}")));
}
Some(lines.join("\n"))
}
fn describe_scene_context(context: &Value) -> String {
let record = as_record(context);
let scene_name = record
.and_then(|item| read_string(item.get("sceneName")))
.unwrap_or_else(|| "当前区域".to_string());
let scene_description = record
.and_then(|item| read_string(item.get("sceneDescription")))
.unwrap_or_else(|| "周围气氛仍未完全安定。".to_string());
let in_battle = if record
.and_then(|item| read_bool(item.get("inBattle")))
.unwrap_or(false)
{
"战斗中"
} else {
"非战斗"
};
let custom_world_profile = record
.and_then(|item| item.get("customWorldProfile"))
.and_then(as_record);
let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name")));
let custom_world_summary =
custom_world_profile.and_then(|item| read_string(item.get("summary")));
[
Some(format!(
"世界补充:{}",
custom_world_name.unwrap_or_else(|| "".to_string())
)),
custom_world_summary.map(|text| format!("世界摘要:{text}")),
Some(format!("场景:{scene_name}")),
Some(format!("场景描述:{scene_description}")),
Some(format!("当前状态:{in_battle}")),
Some(describe_stats("玩家", record)),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n")
}
fn describe_monsters(monsters: &[Value]) -> String {
if monsters.is_empty() {
return "当前敌对目标:无。".to_string();
}
let lines = monsters
.iter()
.take(4)
.filter_map(|item| {
let record = as_record(item)?;
let name = read_string(record.get("name"))
.or_else(|| read_string(record.get("npcName")))
.or_else(|| read_string(record.get("id")))?;
let hp = read_number(record.get("hp")).unwrap_or(0.0);
let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0);
Some(format!(
"- {name}(生命 {}/{})",
format_prompt_number(hp),
format_prompt_number(max_hp)
))
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前敌对目标:无。".to_string()
} else {
let mut result = vec!["当前敌对目标:".to_string()];
result.extend(lines);
result.join("\n")
}
}
fn read_string(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn read_number(value: Option<&Value>) -> Option<f64> {
value
.and_then(Value::as_f64)
.filter(|number| number.is_finite())
}
fn read_bool(value: Option<&Value>) -> Option<bool> {
value.and_then(Value::as_bool)
}
fn read_string_array(value: &Value) -> Vec<String> {
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| read_string(Some(item)))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn as_record(value: &Value) -> Option<&serde_json::Map<String, Value>> {
value.as_object()
}
fn format_prompt_number(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
value.to_string()
}
}