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

1623 lines
50 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 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<String>,
}
#[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<String>,
pub anchor_content_json: String,
pub creator_intent_json: Option<String>,
pub creator_intent_readiness_json: String,
pub anchor_pack_json: Option<String>,
pub draft_profile_json: Option<String>,
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<String>,
}
#[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<PromptUserInputSignal>,
drift_risk: Option<PromptDriftRisk>,
conversation_mode: Option<PromptConversationMode>,
judgement_summary: Option<String>,
}
#[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<String>,
#[serde(default)]
aesthetic_directives: Vec<String>,
#[serde(default)]
forbidden_directives: Vec<String>,
}
#[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<String>,
#[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<String>,
#[serde(default)]
misdirection_hints: Vec<String>,
#[serde(default)]
reveal_pacing: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IconicElementValue {
#[serde(default)]
iconic_motifs: Vec<String>,
#[serde(default)]
institutions_or_artifacts: Vec<String>,
#[serde(default)]
hard_rules: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EightAnchorContent {
#[serde(default)]
world_promise: Option<WorldPromiseValue>,
#[serde(default)]
player_fantasy: Option<PlayerFantasyValue>,
#[serde(default)]
theme_boundary: Option<ThemeBoundaryValue>,
#[serde(default)]
player_entry_point: Option<PlayerEntryPointValue>,
#[serde(default)]
core_conflict: Option<CoreConflictValue>,
#[serde(default)]
key_relationships: Vec<KeyRelationshipValue>,
#[serde(default)]
hidden_lines: Option<HiddenLineValue>,
#[serde(default)]
iconic_elements: Option<IconicElementValue>,
}
#[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<String>,
tone_directives: Vec<String>,
player_premise: String,
opening_situation: String,
core_conflicts: Vec<String>,
key_characters: Vec<CreatorCharacterSeedRecord>,
key_landmarks: Vec<CreatorLandmarkSeedRecord>,
iconic_elements: Vec<String>,
forbidden_directives: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreatorIntentReadiness {
is_ready: bool,
completed_keys: Vec<String>,
missing_keys: Vec<String>,
}
#[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<String>) -> 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<F>(
request: CustomWorldAgentTurnRequest<'_>,
on_reply_update: F,
) -> Result<CustomWorldAgentTurnResult, CustomWorldTurnError>
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,
&current_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::<Vec<_>>(),
),
&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::<Vec<_>>(),
),
&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<String> {
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::<Vec<_>>(),
),
&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<F>(
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<SingleTurnModelOutput, CustomWorldTurnError>
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<PromptDynamicStateInference>,
) -> 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<JsonValue> {
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::<EightAnchorContent>(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::<Vec<_>>();
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::<Vec<_>>()
})
.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::<Vec<_>>()
})
.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::<Vec<_>>(),
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<JsonValue> {
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::<Vec<_>>(),
"motifDirectives": dedupe_string_list(
intent
.theme_keywords
.iter()
.cloned()
.chain(intent.tone_directives.iter().cloned())
.chain(intent.iconic_elements.iter().cloned())
.collect::<Vec<_>>(),
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::<Vec<_>>(),
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>(),
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::<Vec<_>>();
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<String> {
text.split(|character| matches!(character, '。' | '' | '' | '' | '\n'))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn compact_lines<I, S>(items: I) -> String
where
I: IntoIterator<Item = Option<S>>,
S: AsRef<str>,
{
items
.into_iter()
.filter_map(|item| item.map(|value| value.as_ref().trim().to_string()))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.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::<Vec<_>>();
let meaningful = segments
.iter()
.copied()
.filter(|item| !matches!(*item, "玩家" | "主角" | ""))
.collect::<Vec<_>>();
(
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<String>, max_count: usize) -> Vec<String> {
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::<Vec<_>>().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::<String>()
+ ""
}
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<String> {
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("你好,潮雾列岛"));
}
}