use module_custom_world::{ empty_agent_anchor_content_json, empty_agent_asset_coverage_json, empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object, }; use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Value as JsonValue, json}; use crate::custom_world_rpg_draft_prompts::{ BASE_SYSTEM_PROMPT, GLOBAL_HARD_RULES, OUTPUT_CONTRACT_REMINDER, QUICK_FILL_EXTRA_RULES, STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT, extract_reply_text_from_partial_json, mode_rules, parse_conversation_mode, parse_drift_risk, parse_json_response_text, parse_user_input_signal, render_chat_history_context, render_current_anchor_context, render_dynamic_state_context, user_signal_rules, }; use spacetime_client::{ CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentSessionRecord, }; #[derive(Clone, Debug)] pub(crate) struct CustomWorldAgentTurnRequest<'a> { pub llm_client: Option<&'a LlmClient>, pub session: &'a CustomWorldAgentSessionRecord, pub quick_fill_requested: bool, pub focus_card_id: Option, } #[derive(Clone, Debug)] pub(crate) struct CustomWorldAgentTurnResult { pub assistant_reply_text: String, pub phase_label: String, pub phase_detail: String, pub operation_status: String, pub operation_progress: u32, pub stage: String, pub progress_percent: u32, pub focus_card_id: Option, pub anchor_content_json: String, pub creator_intent_json: Option, pub creator_intent_readiness_json: String, pub anchor_pack_json: Option, pub draft_profile_json: Option, pub pending_clarifications_json: String, pub suggested_actions_json: String, pub recommended_replies_json: String, pub quality_findings_json: String, pub asset_coverage_json: String, pub error_message: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PromptUserInputSignal { Rich, Normal, Sparse, Correction, Delegate, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PromptDriftRisk { Low, Medium, High, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PromptConversationMode { Bootstrap, Expand, Compress, RepairDirection, ForceComplete, Closing, } #[derive(Clone, Debug)] #[allow(dead_code)] pub(crate) struct PromptDynamicState { current_turn: u32, progress_percent: u32, pub(crate) user_input_signal: PromptUserInputSignal, pub(crate) drift_risk: PromptDriftRisk, quick_fill_requested: bool, pub(crate) conversation_mode: PromptConversationMode, pub(crate) judgement_summary: String, } #[derive(Clone, Debug, Default)] pub(crate) struct PromptDynamicStateInference { user_input_signal: Option, drift_risk: Option, conversation_mode: Option, judgement_summary: Option, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct WorldPromiseValue { #[serde(default)] hook: String, #[serde(default)] differentiator: String, #[serde(default)] desired_experience: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PlayerFantasyValue { #[serde(default)] player_role: String, #[serde(default)] core_pursuit: String, #[serde(default)] fear_of_loss: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct ThemeBoundaryValue { #[serde(default)] tone_keywords: Vec, #[serde(default)] aesthetic_directives: Vec, #[serde(default)] forbidden_directives: Vec, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PlayerEntryPointValue { #[serde(default)] opening_identity: String, #[serde(default)] opening_problem: String, #[serde(default)] entry_motivation: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CoreConflictValue { #[serde(default)] surface_conflicts: Vec, #[serde(default)] hidden_crisis: String, #[serde(default)] first_touched_conflict: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct KeyRelationshipValue { #[serde(default)] pairs: String, #[serde(default)] relationship_type: String, #[serde(default)] secret_or_cost: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct HiddenLineValue { #[serde(default)] hidden_truths: Vec, #[serde(default)] misdirection_hints: Vec, #[serde(default)] reveal_pacing: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct IconicElementValue { #[serde(default)] iconic_motifs: Vec, #[serde(default)] institutions_or_artifacts: Vec, #[serde(default)] hard_rules: Vec, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct EightAnchorContent { #[serde(default)] world_promise: Option, #[serde(default)] player_fantasy: Option, #[serde(default)] theme_boundary: Option, #[serde(default)] player_entry_point: Option, #[serde(default)] core_conflict: Option, #[serde(default)] key_relationships: Vec, #[serde(default)] hidden_lines: Option, #[serde(default)] iconic_elements: Option, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CreatorCharacterSeedRecord { id: String, name: String, role: String, public_mask: String, hidden_hook: String, relation_to_player: String, notes: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CreatorLandmarkSeedRecord { id: String, name: String, purpose: String, mood: String, secret: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CreatorIntentRecord { source_mode: String, raw_setting_text: String, world_hook: String, theme_keywords: Vec, tone_directives: Vec, player_premise: String, opening_situation: String, core_conflicts: Vec, key_characters: Vec, key_landmarks: Vec, iconic_elements: Vec, forbidden_directives: Vec, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CreatorIntentReadiness { is_ready: bool, completed_keys: Vec, missing_keys: Vec, } #[derive(Clone, Debug)] struct SingleTurnModelOutput { next_anchor_content: EightAnchorContent, progress_percent: u32, reply_text: String, } #[derive(Clone, Debug)] pub(crate) struct CustomWorldTurnError { message: String, } impl CustomWorldTurnError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl std::fmt::Display for CustomWorldTurnError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.message) } } impl std::error::Error for CustomWorldTurnError {} pub(crate) async fn run_custom_world_agent_turn( request: CustomWorldAgentTurnRequest<'_>, on_reply_update: F, ) -> Result where F: FnMut(&str), { let current_anchor_content = normalize_eight_anchor_content(&request.session.anchor_content); let should_preserve_draft_stage = matches!( request.session.stage.as_str(), "object_refining" | "visual_refining" ) && !request.session.draft_cards.is_empty(); let assistant_turn = stream_single_turn( request.llm_client, request.session.messages.as_slice(), request.session.current_turn.saturating_add(1), request.session.progress_percent, request.quick_fill_requested, ¤t_anchor_content, on_reply_update, ) .await?; let next_anchor_content = assistant_turn.next_anchor_content.clone(); let next_creator_intent = build_creator_intent_from_eight_anchor_content(&next_anchor_content); let progress_percent = assistant_turn.progress_percent; let creator_intent_readiness = if progress_percent >= 100 { CreatorIntentReadiness { is_ready: true, completed_keys: vec![ "world_hook".to_string(), "player_premise".to_string(), "theme_and_tone".to_string(), "core_conflict".to_string(), "relationship_seed".to_string(), "iconic_element".to_string(), ], missing_keys: Vec::new(), } } else { evaluate_creator_intent_readiness(&next_creator_intent) }; let derived_stage = resolve_creator_intent_stage(true, &creator_intent_readiness); let should_stay_in_draft_stage = should_preserve_draft_stage && progress_percent >= 100; let next_stage = if should_stay_in_draft_stage { if request.session.stage == "visual_refining" { "visual_refining".to_string() } else { "object_refining".to_string() } } else { derived_stage.to_string() }; let anchor_content_json = serialize_json( &serde_json::to_value(&next_anchor_content).unwrap_or_else(|_| json!({})), &empty_agent_anchor_content_json(), ); let creator_intent_json = Some(serialize_json( &serde_json::to_value(&next_creator_intent).unwrap_or_else(|_| json!({})), &empty_json_object(), )); let creator_intent_readiness_json = serialize_json( &serde_json::to_value(&creator_intent_readiness).unwrap_or_else(|_| json!({})), &empty_agent_creator_intent_readiness_json(), ); let anchor_pack_json = Some(serialize_json( &build_anchor_pack_from_eight_anchor_content(&next_anchor_content, progress_percent), &empty_json_object(), )); let pending_clarifications_json = if should_stay_in_draft_stage || progress_percent >= 100 { empty_json_array() } else { serialize_json( &JsonValue::Array(build_pending_clarifications( &next_creator_intent, &creator_intent_readiness, )), &empty_json_array(), ) }; let suggested_actions_json = if should_stay_in_draft_stage { serialize_json( &JsonValue::Array( request .session .suggested_actions .iter() .cloned() .collect::>(), ), &empty_json_array(), ) } else if progress_percent >= 100 { r#"[{"id":"draft_foundation","type":"draft_foundation","label":"生成游戏设定草稿"}]"# .to_string() } else { empty_json_array() }; let recommended_replies_json = empty_json_array(); let quality_findings_json = if should_stay_in_draft_stage { serialize_json( &JsonValue::Array( request .session .quality_findings .iter() .cloned() .collect::>(), ), &empty_json_array(), ) } else { empty_json_array() }; let asset_coverage_json = if should_stay_in_draft_stage { serialize_json( &request.session.asset_coverage, &empty_agent_asset_coverage_json(), ) } else { empty_agent_asset_coverage_json() }; let draft_profile_json = if should_stay_in_draft_stage { Some(serialize_json( &request.session.draft_profile, &empty_json_object(), )) } else { Some(serialize_json( &build_minimal_draft_profile_from_intent(&next_creator_intent), &empty_json_object(), )) }; Ok(CustomWorldAgentTurnResult { assistant_reply_text: assistant_turn.reply_text, phase_label: "消息已处理".to_string(), phase_detail: "本轮回复已由大模型生成并回写会话。".to_string(), operation_status: "completed".to_string(), operation_progress: 100, stage: next_stage, progress_percent, focus_card_id: if should_stay_in_draft_stage { request.session.focus_card_id.clone() } else { request.focus_card_id }, anchor_content_json, creator_intent_json, creator_intent_readiness_json, anchor_pack_json, draft_profile_json, pending_clarifications_json, suggested_actions_json, recommended_replies_json, quality_findings_json, asset_coverage_json, error_message: None, }) } pub(crate) fn build_finalize_record_input( session_id: String, owner_user_id: String, operation_id: String, assistant_message_id: String, result: CustomWorldAgentTurnResult, updated_at_micros: i64, ) -> CustomWorldAgentMessageFinalizeRecordInput { CustomWorldAgentMessageFinalizeRecordInput { session_id, owner_user_id, operation_id, assistant_message_id: Some(assistant_message_id), assistant_reply_text: Some(result.assistant_reply_text), phase_label: result.phase_label, phase_detail: result.phase_detail, operation_status: result.operation_status, operation_progress: result.operation_progress, stage: result.stage, progress_percent: result.progress_percent, focus_card_id: result.focus_card_id, anchor_content_json: result.anchor_content_json, creator_intent_json: result.creator_intent_json, creator_intent_readiness_json: result.creator_intent_readiness_json, anchor_pack_json: result.anchor_pack_json, draft_profile_json: result.draft_profile_json, pending_clarifications_json: result.pending_clarifications_json, suggested_actions_json: result.suggested_actions_json, recommended_replies_json: result.recommended_replies_json, quality_findings_json: result.quality_findings_json, asset_coverage_json: result.asset_coverage_json, error_message: result.error_message, updated_at_micros, } } fn serialize_optional_json_object(value: &JsonValue) -> Option { if value.is_null() { None } else { Some(serialize_json(value, &empty_json_object())) } } fn serialize_string_array(values: &[String]) -> String { serialize_json( &JsonValue::Array( values .iter() .cloned() .map(JsonValue::String) .collect::>(), ), &empty_json_array(), ) } pub(crate) fn build_failed_finalize_record_input( session_id: String, owner_user_id: String, operation_id: String, session: &CustomWorldAgentSessionRecord, error_message: String, updated_at_micros: i64, ) -> CustomWorldAgentMessageFinalizeRecordInput { CustomWorldAgentMessageFinalizeRecordInput { session_id, owner_user_id, operation_id, assistant_message_id: None, assistant_reply_text: None, phase_label: "消息处理失败".to_string(), phase_detail: error_message.clone(), operation_status: "failed".to_string(), operation_progress: 100, stage: session.stage.clone(), progress_percent: session.progress_percent, focus_card_id: session.focus_card_id.clone(), anchor_content_json: serialize_json( &session.anchor_content, &empty_agent_anchor_content_json(), ), creator_intent_json: serialize_optional_json_object(&session.creator_intent), creator_intent_readiness_json: serialize_json( &session.creator_intent_readiness, &empty_agent_creator_intent_readiness_json(), ), anchor_pack_json: serialize_optional_json_object(&session.anchor_pack), draft_profile_json: serialize_optional_json_object(&session.draft_profile), pending_clarifications_json: serialize_json( &JsonValue::Array(session.pending_clarifications.clone()), &empty_json_array(), ), suggested_actions_json: serialize_json( &JsonValue::Array(session.suggested_actions.clone()), &empty_json_array(), ), recommended_replies_json: serialize_string_array(&session.recommended_replies), quality_findings_json: serialize_json( &JsonValue::Array(session.quality_findings.clone()), &empty_json_array(), ), asset_coverage_json: serialize_json( &session.asset_coverage, &empty_agent_asset_coverage_json(), ), error_message: Some(error_message), updated_at_micros, } } async fn stream_single_turn( llm_client: Option<&LlmClient>, messages: &[CustomWorldAgentMessageRecord], current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, mut on_reply_update: F, ) -> Result where F: FnMut(&str), { let llm_client = llm_client.ok_or_else(|| CustomWorldTurnError::new("当前模型不可用,请稍后重试。"))?; let chat_history = build_chat_history(messages); let dynamic_state = resolve_dynamic_state( llm_client, current_turn, progress_percent, quick_fill_requested, current_anchor_content, &chat_history, ) .await; let prompt = build_eight_anchor_single_turn_prompt( current_turn, progress_percent, quick_fill_requested, current_anchor_content, &chat_history, &dynamic_state, ); let mut latest_reply_text = String::new(); let response = llm_client .stream_text( LlmTextRequest::new(vec![ LlmMessage::system(prompt), LlmMessage::user("请按约定输出这一轮的 JSON。"), ]), |delta: &LlmStreamDelta| { if let Some(reply_progress) = extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) && reply_progress != latest_reply_text { latest_reply_text = reply_progress.clone(); on_reply_update(reply_progress.as_str()); } }, ) .await; let response = response.map_err(|_| CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。"))?; let parsed = parse_json_response_text(response.content.as_str()) .map_err(|_| CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。"))?; let next_anchor_content = normalize_eight_anchor_content(parsed.get("nextAnchorContent").unwrap_or(&JsonValue::Null)); let progress_percent = if quick_fill_requested { 100 } else { clamp_progress_percent(parsed.get("progressPercent")) }; let reply_text = to_text(parsed.get("replyText")) .ok_or_else(|| CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。"))?; if reply_text != latest_reply_text { on_reply_update(reply_text.as_str()); } Ok(SingleTurnModelOutput { next_anchor_content, progress_percent, reply_text, }) } async fn resolve_dynamic_state( llm_client: &LlmClient, current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], ) -> PromptDynamicState { let fallback = build_prompt_dynamic_state( current_turn, progress_percent, quick_fill_requested, current_anchor_content, chat_history, None, ); let (system_prompt, user_prompt) = build_prompt_dynamic_state_inference_prompt( current_turn, progress_percent, quick_fill_requested, current_anchor_content, chat_history, ); let response = llm_client .request_text(LlmTextRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ])) .await; let Ok(response) = response else { return fallback; }; let Ok(parsed) = parse_json_response_text(response.content.as_str()) else { return fallback; }; build_prompt_dynamic_state( current_turn, progress_percent, quick_fill_requested, current_anchor_content, chat_history, Some(PromptDynamicStateInference { user_input_signal: parse_user_input_signal(parsed.get("userInputSignal")), drift_risk: parse_drift_risk(parsed.get("driftRisk")), conversation_mode: parse_conversation_mode(parsed.get("conversationMode")), judgement_summary: to_text(parsed.get("judgementSummary")), }), ) } fn build_prompt_dynamic_state( current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], inference: Option, ) -> PromptDynamicState { let fallback = build_rule_based_prompt_dynamic_state( current_turn, progress_percent, quick_fill_requested, current_anchor_content, chat_history, ); let Some(inference) = inference else { return fallback; }; let user_input_signal = inference .user_input_signal .unwrap_or(fallback.user_input_signal); let drift_risk = inference.drift_risk.unwrap_or(fallback.drift_risk); let conversation_mode = inference .conversation_mode .unwrap_or(fallback.conversation_mode); let judgement_summary = inference .judgement_summary .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| { summarize_dynamic_state(user_input_signal, drift_risk, conversation_mode) }); PromptDynamicState { current_turn, progress_percent, user_input_signal, drift_risk, quick_fill_requested, conversation_mode, judgement_summary, } } fn build_rule_based_prompt_dynamic_state( current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], ) -> PromptDynamicState { let user_input_signal = detect_user_input_signal(chat_history); let drift_risk = detect_drift_risk(chat_history, current_anchor_content, progress_percent); let conversation_mode = pick_conversation_mode( current_turn, progress_percent, user_input_signal, drift_risk, quick_fill_requested, ); PromptDynamicState { current_turn, progress_percent, user_input_signal, drift_risk, quick_fill_requested, conversation_mode, judgement_summary: summarize_dynamic_state( user_input_signal, drift_risk, conversation_mode, ), } } fn build_prompt_dynamic_state_inference_prompt( current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], ) -> (String, String) { ( [ STATE_INFERENCE_SYSTEM_PROMPT, STATE_INFERENCE_OUTPUT_CONTRACT, ] .join("\n\n"), [ format!("当前轮次:{current_turn}"), format!("当前完成度:{progress_percent}"), format!( "是否要求自动补全:{}", if quick_fill_requested { "是" } else { "否" } ), render_current_anchor_context(current_anchor_content), render_chat_history_context(chat_history), ] .join("\n\n"), ) } fn build_eight_anchor_single_turn_prompt( _current_turn: u32, _progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], dynamic_state: &PromptDynamicState, ) -> String { let mut blocks = vec![ BASE_SYSTEM_PROMPT.to_string(), GLOBAL_HARD_RULES.to_string(), mode_rules(dynamic_state.conversation_mode).to_string(), user_signal_rules(dynamic_state.user_input_signal).to_string(), ]; if quick_fill_requested { blocks.push(QUICK_FILL_EXTRA_RULES.to_string()); } blocks.push(render_dynamic_state_context(dynamic_state)); blocks.push(render_current_anchor_context(current_anchor_content)); blocks.push(render_chat_history_context(chat_history)); blocks.push(OUTPUT_CONTRACT_REMINDER.to_string()); blocks.join("\n\n") } fn build_chat_history(messages: &[CustomWorldAgentMessageRecord]) -> Vec { messages .iter() .filter(|message| { (message.role == "user" || message.role == "assistant") && !message.text.trim().is_empty() }) .map(|message| { json!({ "role": message.role, "content": message.text, }) }) .collect() } fn normalize_eight_anchor_content(value: &JsonValue) -> EightAnchorContent { serde_json::from_value::(value.clone()).unwrap_or_default() } fn build_creator_intent_from_eight_anchor_content( anchor_content: &EightAnchorContent, ) -> CreatorIntentRecord { let key_characters = anchor_content .key_relationships .iter() .enumerate() .map(|(index, entry)| { let (lead_name, relation_to_player) = split_relationship_pair(entry.pairs.as_str()); CreatorCharacterSeedRecord { id: format!("creator-character-{}", index + 1), name: if lead_name.is_empty() { format!("关键人物{}", index + 1) } else { lead_name }, role: entry.relationship_type.clone(), public_mask: String::new(), hidden_hook: entry.secret_or_cost.clone(), relation_to_player, notes: String::new(), } }) .collect::>(); let core_conflicts = anchor_content .core_conflict .as_ref() .map(|value| { value .surface_conflicts .iter() .cloned() .chain( (!value.hidden_crisis.trim().is_empty()).then_some(value.hidden_crisis.clone()), ) .collect::>() }) .unwrap_or_default(); CreatorIntentRecord { source_mode: "freeform".to_string(), raw_setting_text: compact_lines([ anchor_content .world_promise .as_ref() .map(|value| value.differentiator.as_str()), anchor_content .player_fantasy .as_ref() .map(|value| value.core_pursuit.as_str()), anchor_content .hidden_lines .as_ref() .and_then(|value| value.hidden_truths.first().map(String::as_str)), ]), world_hook: compact_lines([ anchor_content .world_promise .as_ref() .map(|value| value.hook.as_str()), anchor_content .world_promise .as_ref() .map(|value| value.differentiator.as_str()), ]), theme_keywords: anchor_content .theme_boundary .as_ref() .map(|value| value.tone_keywords.clone()) .unwrap_or_default(), tone_directives: anchor_content .theme_boundary .as_ref() .map(|value| value.aesthetic_directives.clone()) .unwrap_or_default(), player_premise: compact_lines([ anchor_content .player_fantasy .as_ref() .map(|value| value.player_role.as_str()), anchor_content .player_entry_point .as_ref() .map(|value| value.opening_identity.as_str()), ]), opening_situation: compact_lines([ anchor_content .player_entry_point .as_ref() .map(|value| value.opening_problem.as_str()), anchor_content .player_entry_point .as_ref() .map(|value| value.entry_motivation.as_str()), ]), core_conflicts: dedupe_string_list(core_conflicts, 6), key_characters, key_landmarks: Vec::new(), iconic_elements: dedupe_string_list( anchor_content .iconic_elements .as_ref() .map(|value| { value .iconic_motifs .iter() .cloned() .chain(value.institutions_or_artifacts.iter().cloned()) .collect::>() }) .unwrap_or_default(), 8, ), forbidden_directives: dedupe_string_list( anchor_content .theme_boundary .as_ref() .map(|value| value.forbidden_directives.clone()) .unwrap_or_default() .into_iter() .chain( anchor_content .iconic_elements .as_ref() .map(|value| value.hard_rules.clone()) .unwrap_or_default(), ) .collect::>(), 8, ), } } fn evaluate_creator_intent_readiness(intent: &CreatorIntentRecord) -> CreatorIntentReadiness { let relationship_ready = intent.key_characters.iter().any(|entry| { !entry.name.trim().is_empty() && (!entry.relation_to_player.trim().is_empty() || !entry.hidden_hook.trim().is_empty()) }); let checks = [ ( "world_hook", intent.world_hook.trim().chars().count() >= 8 || intent.raw_setting_text.trim().chars().count() >= 24, ), ( "player_premise", !intent.player_premise.trim().is_empty() && !intent.opening_situation.trim().is_empty(), ), ( "theme_and_tone", !intent.theme_keywords.is_empty() && !intent.tone_directives.is_empty(), ), ("core_conflict", !intent.core_conflicts.is_empty()), ( "relationship_seed", !intent.key_characters.is_empty() && relationship_ready, ), ("iconic_element", !intent.iconic_elements.is_empty()), ]; let mut completed_keys = Vec::new(); let mut missing_keys = Vec::new(); for (key, ready) in checks { if ready { completed_keys.push(key.to_string()); } else { missing_keys.push(key.to_string()); } } CreatorIntentReadiness { is_ready: missing_keys.is_empty(), completed_keys, missing_keys, } } fn resolve_creator_intent_stage( has_user_input: bool, readiness: &CreatorIntentReadiness, ) -> &'static str { if readiness.is_ready { "foundation_review" } else if has_user_input { "clarifying" } else { "collecting_intent" } } fn build_pending_clarifications( _intent: &CreatorIntentRecord, readiness: &CreatorIntentReadiness, ) -> Vec { let definitions = [ ( "world_hook", 1, "世界一句话", "先用一句话收住这个世界最独特的核心幻想,我会据此继续往下补。", ), ( "player_premise", 2, "玩家身份与开局", "玩家是谁,故事开场时卡在什么处境里?你可以把身份和开局困境一起告诉我。", ), ( "core_conflict", 3, "核心冲突", "现在推动这个世界往前走的主要冲突是什么?最好是能立刻形成剧情压力的那种。", ), ( "theme_and_tone", 4, "主题气质", "它整体更偏什么主题和气质?比如冷峻、压迫、浪漫、潮湿,也可以顺手告诉我不要什么。", ), ( "relationship_seed", 5, "关键关系钩子", "给我一个关键人物种子就行,他和玩家是什么关系,或者他藏着什么暗线?", ), ( "iconic_element", 6, "标志性要素", "这个世界至少给我 1 个一眼能认出来的标志性元素、机制或意象。", ), ]; definitions .iter() .filter(|(target_key, _, _, _)| readiness.missing_keys.iter().any(|key| key == target_key)) .take(1) .map(|(target_key, priority, label, question)| { json!({ "id": target_key, "label": label, "question": question, "targetKey": target_key, "priority": priority, }) }) .collect() } fn build_anchor_pack_from_eight_anchor_content( anchor_content: &EightAnchorContent, progress_percent: u32, ) -> JsonValue { let intent = build_creator_intent_from_eight_anchor_content(anchor_content); let completed_keys = if progress_percent >= 100 { vec!["eight_anchor_minimum_loop".to_string()] } else { Vec::new() }; let missing_keys = if progress_percent >= 100 { Vec::new() } else { vec!["eight_anchor_minimum_loop".to_string()] }; json!({ "worldSummary": clamp_text( if !intent.world_hook.trim().is_empty() { intent.world_hook.as_str() } else { intent.raw_setting_text.as_str() }, 96, ), "creatorIntentSummary": clamp_text(build_draft_summary_from_intent(&intent).as_str(), 180), "completedKeys": completed_keys, "missingKeys": missing_keys, "keyCharacterAnchors": intent .key_characters .iter() .map(|entry| { json!({ "id": entry.id, "name": if entry.name.trim().is_empty() { "未命名关键人物" } else { entry.name.as_str() }, "summary": clamp_text( compact_lines([ Some(entry.role.as_str()), Some(entry.relation_to_player.as_str()), Some(entry.hidden_hook.as_str()), ]) .as_str(), 60, ), }) }) .collect::>(), "motifDirectives": dedupe_string_list( intent .theme_keywords .iter() .cloned() .chain(intent.tone_directives.iter().cloned()) .chain(intent.iconic_elements.iter().cloned()) .collect::>(), 12, ), }) } fn build_minimal_draft_profile_from_intent(intent: &CreatorIntentRecord) -> JsonValue { let title = build_draft_title_from_intent(intent); let summary = build_draft_summary_from_intent(intent); let subtitle = clamp_text( format!( "{} · {}", if !intent.player_premise.trim().is_empty() { intent.player_premise.as_str() } else { "玩家入口待继续细化" }, intent .core_conflicts .first() .map(String::as_str) .unwrap_or("核心冲突仍在整理") ) .as_str(), 40, ); let tone = clamp_text( dedupe_string_list( intent .theme_keywords .iter() .cloned() .chain(intent.tone_directives.iter().cloned()) .collect::>(), 8, ) .join("、") .as_str(), 60, ); let playable_npcs = intent .key_characters .iter() .map(|entry| { json!({ "id": entry.id, "name": entry.name, "title": entry.role, "role": entry.role, "publicMask": entry.public_mask, "publicIdentity": entry.public_mask, "hiddenHook": entry.hidden_hook, "currentPressure": entry.hidden_hook, "relationToPlayer": entry.relation_to_player, "summary": clamp_text( compact_lines([ Some(entry.role.as_str()), Some(entry.relation_to_player.as_str()), Some(entry.hidden_hook.as_str()), ]) .as_str(), 120, ), "threadIds": [], "skills": [], }) }) .collect::>(); let landmarks = intent .key_landmarks .iter() .map(|entry| { json!({ "id": entry.id, "name": entry.name, "purpose": entry.purpose, "mood": entry.mood, "secret": entry.secret, "summary": clamp_text( compact_lines([ Some(entry.purpose.as_str()), Some(entry.mood.as_str()), Some(entry.secret.as_str()), ]) .as_str(), 120, ), }) }) .collect::>(); json!({ "name": title, "title": title, "subtitle": subtitle, "summary": summary, "tone": if tone.is_empty() { "整体气质仍可继续精修" } else { tone.as_str() }, "playerGoal": intent .core_conflicts .first() .cloned() .unwrap_or_else(|| "先站稳开局,再判断下一步".to_string()), "worldHook": intent.world_hook, "playerPremise": intent.player_premise, "coreConflicts": intent.core_conflicts, "playableNpcs": playable_npcs, "storyNpcs": [], "landmarks": landmarks, "factions": [], "threads": [], "chapters": [], "sceneChapters": [], }) } fn build_draft_title_from_intent(intent: &CreatorIntentRecord) -> String { let world_hook = clamp_text(intent.world_hook.as_str(), 24); if !world_hook.is_empty() { return world_hook; } let raw_setting = clamp_text(intent.raw_setting_text.as_str(), 24); if !raw_setting.is_empty() { return raw_setting; } "未命名草稿".to_string() } fn build_draft_summary_from_intent(intent: &CreatorIntentRecord) -> String { let display = build_creator_intent_display_text(intent); if !display.is_empty() { return clamp_text(display.replace('\n', " · ").as_str(), 180); } let raw_setting = clamp_text(intent.raw_setting_text.as_str(), 180); if !raw_setting.is_empty() { return raw_setting; } "还在收集你的世界锚点。".to_string() } fn build_creator_intent_display_text(intent: &CreatorIntentRecord) -> String { let mut lines = Vec::new(); if !intent.world_hook.trim().is_empty() { lines.push(format!("世界一句话:{}", intent.world_hook)); } if !intent.player_premise.trim().is_empty() { lines.push(format!("玩家身份:{}", intent.player_premise)); } if !intent.opening_situation.trim().is_empty() { lines.push(format!("开局处境:{}", intent.opening_situation)); } if !intent.core_conflicts.is_empty() { lines.push(format!("核心冲突:{}", intent.core_conflicts.join("、"))); } let theme_text = dedupe_string_list( intent .theme_keywords .iter() .cloned() .chain(intent.tone_directives.iter().cloned()) .collect::>(), 8, ) .join("、"); if !theme_text.is_empty() { lines.push(format!("主题气质:{}", theme_text)); } if !intent.iconic_elements.is_empty() { lines.push(format!("标志性要素:{}", intent.iconic_elements.join("、"))); } lines.join("\n") } fn detect_user_input_signal(chat_history: &[JsonValue]) -> PromptUserInputSignal { let latest_user_text = latest_user_text(chat_history); if latest_user_text.is_empty() { return PromptUserInputSignal::Sparse; } if contains_any( &latest_user_text, &["不是", "改成", "改为", "换成", "重来", "推翻", "修正"], ) { return PromptUserInputSignal::Correction; } if contains_any( &latest_user_text, &["你帮我想", "你来定", "你决定", "你补完"], ) { return PromptUserInputSignal::Delegate; } let segments = split_sentences(&latest_user_text); if latest_user_text.chars().count() <= 10 || segments.len() <= 1 { return PromptUserInputSignal::Sparse; } if segments.len() >= 3 || latest_user_text.chars().count() >= 60 { return PromptUserInputSignal::Rich; } PromptUserInputSignal::Normal } fn detect_drift_risk( chat_history: &[JsonValue], anchor_content: &EightAnchorContent, progress_percent: u32, ) -> PromptDriftRisk { let latest_user_text = latest_user_text(chat_history); let recent_user_messages = chat_history .iter() .filter_map(|entry| { (entry.get("role").and_then(JsonValue::as_str) == Some("user")).then(|| { entry .get("content") .and_then(JsonValue::as_str) .unwrap_or("") .trim() .to_string() }) }) .filter(|value| !value.is_empty()) .rev() .take(3) .collect::>(); let correction_count = recent_user_messages .iter() .filter(|entry| { contains_any( entry, &["不是", "改成", "改为", "换成", "推翻", "重来", "修正"], ) }) .count(); if correction_count >= 2 || (progress_percent >= 65 && contains_any( &latest_user_text, &["不是", "改成", "改为", "换成", "重来", "推翻"], )) { return PromptDriftRisk::High; } let filled_count = [ anchor_content.world_promise.is_some(), anchor_content.player_fantasy.is_some(), anchor_content .theme_boundary .as_ref() .map(|value| { !value.tone_keywords.is_empty() || !value.aesthetic_directives.is_empty() || !value.forbidden_directives.is_empty() }) .unwrap_or(false), anchor_content.player_entry_point.is_some(), anchor_content.core_conflict.is_some(), !anchor_content.key_relationships.is_empty(), anchor_content .hidden_lines .as_ref() .map(|value| { !value.hidden_truths.is_empty() || !value.misdirection_hints.is_empty() || !value.reveal_pacing.trim().is_empty() }) .unwrap_or(false), anchor_content .iconic_elements .as_ref() .map(|value| { !value.iconic_motifs.is_empty() || !value.institutions_or_artifacts.is_empty() || !value.hard_rules.is_empty() }) .unwrap_or(false), ] .iter() .filter(|value| **value) .count(); if filled_count >= 3 && latest_user_text.chars().count() >= 40 { PromptDriftRisk::Medium } else { PromptDriftRisk::Low } } fn pick_conversation_mode( current_turn: u32, progress_percent: u32, user_input_signal: PromptUserInputSignal, drift_risk: PromptDriftRisk, quick_fill_requested: bool, ) -> PromptConversationMode { if quick_fill_requested { return PromptConversationMode::ForceComplete; } if matches!(user_input_signal, PromptUserInputSignal::Correction) || matches!(drift_risk, PromptDriftRisk::High) { return PromptConversationMode::RepairDirection; } if progress_percent >= 85 || current_turn >= 15 { return PromptConversationMode::Closing; } if current_turn > 10 || progress_percent >= 65 { return PromptConversationMode::Compress; } if current_turn <= 10 && progress_percent < 65 { return PromptConversationMode::Expand; } PromptConversationMode::Bootstrap } fn summarize_dynamic_state( user_input_signal: PromptUserInputSignal, drift_risk: PromptDriftRisk, conversation_mode: PromptConversationMode, ) -> String { format!( "输入信号={},漂移风险={},本轮模式={}。正式生成时按这组状态执行。", user_input_signal.as_str(), drift_risk.as_str(), conversation_mode.as_str() ) } fn latest_user_text(chat_history: &[JsonValue]) -> String { chat_history .iter() .rev() .find(|entry| entry.get("role").and_then(JsonValue::as_str) == Some("user")) .and_then(|entry| entry.get("content").and_then(JsonValue::as_str)) .map(str::trim) .unwrap_or_default() .to_string() } fn split_sentences(text: &str) -> Vec { text.split(|character| matches!(character, '。' | '!' | '?' | ';' | '\n')) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .collect() } fn compact_lines(items: I) -> String where I: IntoIterator>, S: AsRef, { items .into_iter() .filter_map(|item| item.map(|value| value.as_ref().trim().to_string())) .filter(|value| !value.is_empty()) .collect::>() .join(";") } fn split_relationship_pair(value: &str) -> (String, String) { let replaced = value.replace(['、', '/', '/', '&', '|'], " "); let segments = replaced .split(['与', '和', ' ']) .map(str::trim) .filter(|value| !value.is_empty()) .collect::>(); let meaningful = segments .iter() .copied() .filter(|item| !matches!(*item, "玩家" | "主角" | "我")) .collect::>(); ( meaningful.first().copied().unwrap_or_default().to_string(), if segments.len() >= 2 { segments.join(" / ") } else { value.trim().to_string() }, ) } fn dedupe_string_list(values: Vec, max_count: usize) -> Vec { let mut seen = std::collections::BTreeSet::new(); let mut result = Vec::new(); for value in values { let normalized = value.trim().to_string(); if normalized.is_empty() || !seen.insert(normalized.clone()) { continue; } result.push(normalized); if result.len() >= max_count { break; } } result } fn contains_any(text: &str, patterns: &[&str]) -> bool { patterns.iter().any(|pattern| text.contains(pattern)) } fn clamp_text(value: &str, max_length: usize) -> String { let normalized = value.split_whitespace().collect::>().join(" "); if normalized.is_empty() { return String::new(); } if normalized.chars().count() <= max_length { return normalized; } normalized .chars() .take(max_length.saturating_sub(1)) .collect::() + "…" } fn clamp_progress_percent(value: Option<&JsonValue>) -> u32 { let Some(number) = value.and_then(JsonValue::as_f64) else { return 0; }; number.round().clamp(0.0, 100.0) as u32 } fn to_text(value: Option<&JsonValue>) -> Option { value .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn serialize_json(value: &JsonValue, fallback: &str) -> String { serde_json::to_string(value).unwrap_or_else(|_| fallback.to_string()) } impl PromptUserInputSignal { pub(crate) fn as_str(self) -> &'static str { match self { Self::Rich => "rich", Self::Normal => "normal", Self::Sparse => "sparse", Self::Correction => "correction", Self::Delegate => "delegate", } } } impl PromptDriftRisk { pub(crate) fn as_str(self) -> &'static str { match self { Self::Low => "low", Self::Medium => "medium", Self::High => "high", } } } impl PromptConversationMode { pub(crate) fn as_str(self) -> &'static str { match self { Self::Bootstrap => "bootstrap", Self::Expand => "expand", Self::Compress => "compress", Self::RepairDirection => "repair_direction", Self::ForceComplete => "force_complete", Self::Closing => "closing", } } } #[cfg(test)] mod tests { use crate::custom_world_rpg_draft_prompts::extract_reply_text_from_partial_json; #[test] fn extract_reply_text_from_partial_json_preserves_chinese_characters() { let partial_json = r#"{"replyText":"你好,潮雾列岛","progressPercent":32"#; let extracted = extract_reply_text_from_partial_json(partial_json); assert_eq!(extracted.as_deref(), Some("你好,潮雾列岛")); } }