1611 lines
50 KiB
Rust
1611 lines
50 KiB
Rust
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;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value as JsonValue, json};
|
||
|
||
use crate::creation_agent_llm_turn::{
|
||
CreationAgentLlmTurnErrorMessages, request_creation_agent_json_turn,
|
||
stream_creation_agent_json_turn,
|
||
};
|
||
use crate::custom_world_rpg_draft_prompts::{
|
||
BASE_SYSTEM_PROMPT, GLOBAL_HARD_RULES, OUTPUT_CONTRACT_REMINDER,
|
||
STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT, mode_rules,
|
||
parse_conversation_mode, parse_drift_risk, parse_user_input_signal, quick_fill_extra_rules,
|
||
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,
|
||
¤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::<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,
|
||
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 turn_output = stream_creation_agent_json_turn(
|
||
Some(llm_client),
|
||
prompt,
|
||
"请按约定输出这一轮的 JSON。",
|
||
CreationAgentLlmTurnErrorMessages {
|
||
model_unavailable: "当前模型不可用,请稍后重试。",
|
||
generation_failed: "这一轮设定生成失败,请稍后重试。",
|
||
parse_failed: "模型返回结果解析失败,请稍后重试。",
|
||
},
|
||
on_reply_update,
|
||
CustomWorldTurnError::new,
|
||
)
|
||
.await?;
|
||
let parsed = turn_output.parsed;
|
||
|
||
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("模型返回结果缺少有效回复,请稍后重试。"))?;
|
||
|
||
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 Ok(parsed) = request_creation_agent_json_turn(
|
||
llm_client,
|
||
system_prompt,
|
||
user_prompt,
|
||
CustomWorldTurnError::new,
|
||
)
|
||
.await
|
||
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());
|
||
}
|
||
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::creation_agent_llm_turn::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("你好,潮雾列岛"));
|
||
}
|
||
}
|