1
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
||||
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
|
||||
use spacetime_client::CustomWorldAgentSessionRecord;
|
||||
@@ -738,7 +738,7 @@ fn build_custom_world_landmark_seed_batch_prompt(
|
||||
) -> String {
|
||||
[
|
||||
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
|
||||
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架与默认生图描述。".to_string(),
|
||||
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 0),
|
||||
@@ -751,6 +751,7 @@ fn build_custom_world_landmark_seed_batch_prompt(
|
||||
" \"name\": \"场景名称\",".to_string(),
|
||||
" \"description\": \"场景极简描述\",".to_string(),
|
||||
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
|
||||
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
|
||||
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
@@ -760,8 +761,10 @@ fn build_custom_world_landmark_seed_batch_prompt(
|
||||
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
|
||||
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
|
||||
"- 每个地点只保留:name、description、visualDescription、dangerLevel。".to_string(),
|
||||
"- 每个地点只保留:name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(),
|
||||
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
|
||||
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(),
|
||||
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景;玩家会在……”这类标题、摘要、规则句拼接格式;必须像可直接交给生图模型的自然画面描述。".to_string(),
|
||||
"- description 控制在 12 到 24 个汉字内。".to_string(),
|
||||
"- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(),
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
@@ -780,8 +783,8 @@ fn build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||||
format!("必须保留恰好 {expected_count} 个地点对象。"),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||||
"每个地点只包含:name、description、visualDescription、dangerLevel。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,dangerLevel 补 medium。".to_string(),
|
||||
"每个地点只包含:name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 补空数组,dangerLevel 补 medium。".to_string(),
|
||||
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
@@ -945,6 +948,7 @@ fn build_custom_world_role_batch_json_repair_prompt(
|
||||
response_text.trim().to_string(),
|
||||
].join("\n")
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
|
||||
let anchor_content = to_pretty_json(&session.anchor_content);
|
||||
let creator_intent = to_pretty_json(&session.creator_intent);
|
||||
@@ -1063,11 +1067,80 @@ fn build_foundation_draft_profile_from_framework(
|
||||
JsonValue::Array(playable_detailed),
|
||||
);
|
||||
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
|
||||
let scene_chapter_blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
|
||||
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
|
||||
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
|
||||
object.insert(
|
||||
"sceneChapterBlueprints".to_string(),
|
||||
JsonValue::Array(scene_chapter_blueprints),
|
||||
);
|
||||
normalize_foundation_draft_profile(JsonValue::Object(object), session)
|
||||
}
|
||||
|
||||
fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec<JsonValue> {
|
||||
// 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。
|
||||
landmarks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(chapter_index, landmark)| {
|
||||
let scene_name = json_text(landmark, "name")
|
||||
.unwrap_or_else(|| format!("关键场景{}", chapter_index + 1));
|
||||
let scene_id = json_text(landmark, "id")
|
||||
.unwrap_or_else(|| format!("saved-landmark-{}", chapter_index + 1));
|
||||
let summary = json_text(landmark, "description").unwrap_or_default();
|
||||
let act_prompts =
|
||||
json_string_array(landmark, "actBackgroundPromptTexts").unwrap_or_default();
|
||||
let scene_npc_names = json_string_array(landmark, "sceneNpcNames").unwrap_or_default();
|
||||
|
||||
json!({
|
||||
"id": scene_id.clone(),
|
||||
"sceneId": scene_id.clone(),
|
||||
"title": scene_name,
|
||||
"summary": summary,
|
||||
"linkedLandmarkIds": [scene_id.clone()],
|
||||
"acts": (0..3)
|
||||
.map(|act_index| build_scene_act_blueprint_from_landmark(
|
||||
&scene_id,
|
||||
&summary,
|
||||
&act_prompts,
|
||||
&scene_npc_names,
|
||||
act_index,
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_scene_act_blueprint_from_landmark(
|
||||
scene_id: &str,
|
||||
scene_summary: &str,
|
||||
act_prompts: &[String],
|
||||
scene_npc_names: &[String],
|
||||
act_index: usize,
|
||||
) -> JsonValue {
|
||||
let act_title = if act_index == 0 {
|
||||
"第1幕".to_string()
|
||||
} else {
|
||||
format!("第{}幕", act_index + 1)
|
||||
};
|
||||
let prompt = act_prompts
|
||||
.get(act_index)
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("");
|
||||
// 缺失时保留空值,让后续生图前校验暴露底稿质量问题。
|
||||
json!({
|
||||
"id": format!("{}-act-{}", scene_id, act_index + 1),
|
||||
"sceneId": scene_id,
|
||||
"title": act_title,
|
||||
"summary": scene_summary,
|
||||
"backgroundPromptText": prompt,
|
||||
"encounterNpcIds": scene_npc_names,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
|
||||
if !framework.is_object() {
|
||||
*framework = json!({});
|
||||
@@ -1469,12 +1542,6 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
|
||||
|
||||
fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
|
||||
let mut object = act.as_object().cloned().unwrap_or_default();
|
||||
let fallback_act = build_fallback_scene_act_with_index(index);
|
||||
let fallback_prompt = fallback_act
|
||||
.get("backgroundPromptText")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or("当前幕场景背景,突出可探索空间、站位地面和局势氛围。")
|
||||
.to_string();
|
||||
let title = object
|
||||
.get("title")
|
||||
.and_then(JsonValue::as_str)
|
||||
@@ -1497,7 +1564,7 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("{title}:{summary}。{fallback_prompt}"));
|
||||
.unwrap_or_default();
|
||||
object.insert(
|
||||
"backgroundPromptText".to_string(),
|
||||
JsonValue::String(background_prompt),
|
||||
@@ -1523,7 +1590,7 @@ fn build_fallback_scene_act_with_index(index: usize) -> JsonValue {
|
||||
"id": format!("scene-act-{}", index + 1),
|
||||
"title": if index == 0 { "开场场景幕".to_string() } else { format!("第{}幕", index + 1) },
|
||||
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
|
||||
"backgroundPromptText": "第一幕场景背景,突出玩家初入现场时的空间轮廓、可站立地面、远近景层次和第一波威胁氛围。",
|
||||
"backgroundPromptText": "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1642,10 +1709,12 @@ fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error>
|
||||
serde_json::from_str::<JsonValue>(trimmed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn to_pretty_json(value: &JsonValue) -> String {
|
||||
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn is_non_null_json(value: &JsonValue) -> bool {
|
||||
!matches!(value, JsonValue::Null)
|
||||
}
|
||||
@@ -1665,6 +1734,54 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scene_chapter_blueprints_use_landmark_act_background_prompts() {
|
||||
let landmarks = vec![json!({
|
||||
"name": "雾港码头",
|
||||
"description": "旧船骨露出黑潮。",
|
||||
"actBackgroundPromptTexts": [
|
||||
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。",
|
||||
"封锁绳与巡海灯横切码头,中景堆满浸水货箱,远景黑潮拍打沉船残骸。",
|
||||
"退潮后的泥滩露出父亲留下的海图匣,雾中灯火错位闪烁,岸边留出对峙站位。"
|
||||
],
|
||||
"sceneNpcNames": ["灯童丁"]
|
||||
})];
|
||||
|
||||
let blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
|
||||
let acts = blueprints[0]
|
||||
.get("acts")
|
||||
.and_then(JsonValue::as_array)
|
||||
.expect("acts should exist");
|
||||
|
||||
assert_eq!(acts.len(), 3);
|
||||
assert_eq!(
|
||||
acts[0].get("backgroundPromptText"),
|
||||
Some(&json!(
|
||||
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。"
|
||||
))
|
||||
);
|
||||
assert!(
|
||||
!acts[0]
|
||||
.get("backgroundPromptText")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or_default()
|
||||
.contains("第1幕背景")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_scene_act_keeps_missing_background_prompt_empty() {
|
||||
let act = normalize_scene_act_blueprint(
|
||||
json!({
|
||||
"title": "第1幕",
|
||||
"summary": "玩家进入雾港码头。"
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(act.get("backgroundPromptText"), Some(&json!("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foundation_prompt_uses_real_seed_text() {
|
||||
let session = build_test_session();
|
||||
@@ -1740,7 +1857,7 @@ mod tests {
|
||||
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
|
||||
),
|
||||
llm_response(
|
||||
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
|
||||
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
|
||||
),
|
||||
llm_response(
|
||||
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
|
||||
@@ -1795,6 +1912,24 @@ mod tests {
|
||||
assert!(request_text.contains("叙事档案"));
|
||||
assert!(request_text.contains("养成档案"));
|
||||
assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1"));
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("playableNpcs")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(|entry| entry.get("visualDescription"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("storyNpcs")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(|entry| entry.get("visualDescription"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。")
|
||||
);
|
||||
assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航")));
|
||||
assert!(
|
||||
draft_profile
|
||||
|
||||
Reference in New Issue
Block a user