2125 lines
71 KiB
Rust
2125 lines
71 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, LlmMessage, LlmStreamDelta, LlmTextRequest};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value as JsonValue, json};
|
||
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)]
|
||
enum PromptUserInputSignal {
|
||
Rich,
|
||
Normal,
|
||
Sparse,
|
||
Correction,
|
||
Delegate,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum PromptDriftRisk {
|
||
Low,
|
||
Medium,
|
||
High,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum PromptConversationMode {
|
||
Bootstrap,
|
||
Expand,
|
||
Compress,
|
||
RepairDirection,
|
||
ForceComplete,
|
||
Closing,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
#[allow(dead_code)]
|
||
struct PromptDynamicState {
|
||
current_turn: u32,
|
||
progress_percent: u32,
|
||
user_input_signal: PromptUserInputSignal,
|
||
drift_risk: PromptDriftRisk,
|
||
quick_fill_requested: bool,
|
||
conversation_mode: PromptConversationMode,
|
||
judgement_summary: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
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")]
|
||
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 {}
|
||
|
||
const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
|
||
|
||
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
|
||
1. 当前完整设定结构
|
||
2. 用户聊天记录
|
||
|
||
然后输出:
|
||
1. 一版新的完整设定结构
|
||
2. 当前 progress 百分比
|
||
3. 一段直接回复用户的话
|
||
|
||
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
|
||
你的输出会直接覆盖上一版设定结构。
|
||
|
||
你不是在做局部 patch。
|
||
你不是在做解释报告。
|
||
你不是在给开发者写分析。
|
||
你是在同时完成:
|
||
1. 世界设定更新
|
||
2. 当前推进程度判断
|
||
3. 对用户的共创回复"#;
|
||
|
||
const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
|
||
|
||
1. 必须输出完整的设定结构,而不是只输出变化部分。
|
||
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
|
||
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
|
||
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
|
||
5. progressPercent 最低为 0,不允许为负数。
|
||
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
|
||
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
|
||
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
|
||
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
|
||
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
|
||
11. 你输出的 JSON 必须可以被直接解析。
|
||
12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。"#;
|
||
|
||
const QUICK_FILL_EXTRA_RULES: &str = r#"用户刚刚主动要求你自动补全剩余设定。
|
||
|
||
这表示用户接受你基于当前方向自动补完剩余设定。
|
||
|
||
本轮要求:
|
||
1. 不要再继续提问
|
||
2. 直接输出一版尽量完整的设定结构
|
||
3. progressPercent 直接输出为 100
|
||
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”"#;
|
||
|
||
const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
|
||
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
|
||
|
||
你必须综合以下信息判断:
|
||
1. 当前轮次 currentTurn
|
||
2. 当前完成度 progressPercent
|
||
3. 用户是否要求自动补全 quickFillRequested
|
||
4. 当前完整设定结构
|
||
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
|
||
|
||
你需要输出 4 个字段:
|
||
1. userInputSignal:只能是 rich / normal / sparse / correction / delegate
|
||
2. driftRisk:只能是 low / medium / high
|
||
3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
|
||
4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
|
||
|
||
请按下面的语义判断。
|
||
|
||
一、userInputSignal 定义
|
||
1. rich
|
||
- 用户这一轮给了多条可直接落地的有效信息
|
||
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
|
||
- 正式生成时应优先高密度吸收,不要只更新一个点
|
||
|
||
2. normal
|
||
- 用户在顺着当前方向做正常补充
|
||
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
|
||
- 正式生成时应稳定推进并自然接住用户内容
|
||
|
||
3. sparse
|
||
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
|
||
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
|
||
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
|
||
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
|
||
|
||
4. correction
|
||
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
|
||
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
|
||
- correction 的优先级高于 rich 和 normal
|
||
|
||
5. delegate
|
||
- 用户把部分决定权交给系统
|
||
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
|
||
- delegate 关注的是授权关系,不只是信息多寡
|
||
|
||
二、driftRisk 定义
|
||
1. low
|
||
- 当前轮输入与已有方向基本一致
|
||
- 没有明显改口或冲突
|
||
|
||
2. medium
|
||
- 当前轮带来一定方向变化或扩张
|
||
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
|
||
|
||
3. high
|
||
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
|
||
- 这时最重要的是防止旧方向重新回流到正式生成结果里
|
||
|
||
三、conversationMode 选择原则
|
||
1. bootstrap
|
||
- 适用于前期、信息少、核心方向未稳定
|
||
- replyText 更适合低压力确认和单点启发
|
||
|
||
2. expand
|
||
- 适用于方向已成形,正在顺着现有路线继续补充
|
||
- replyText 更适合总结已接住的内容并往前推一步
|
||
|
||
3. compress
|
||
- 适用于中后段,已有骨架,需要开始收束
|
||
- replyText 更适合聚焦最关键缺口,而不是继续开支线
|
||
|
||
4. repair_direction
|
||
- 适用于用户正在纠偏
|
||
- replyText 更适合先承认修正,再沿修正后的方向继续推进
|
||
|
||
5. force_complete
|
||
- 适用于用户明确要求自动补全
|
||
- replyText 不再提问,而应给出完成感和下一步引导
|
||
|
||
6. closing
|
||
- 适用于接近完成但并非强制一键补全
|
||
- replyText 更像确认与收束,而不是前期式探索
|
||
|
||
四、优先级规则
|
||
1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete
|
||
2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction
|
||
3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate
|
||
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
|
||
|
||
五、关于 replyText 风格的专门判断要求
|
||
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
|
||
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
|
||
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
|
||
4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进
|
||
5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
|
||
|
||
六、关于 replyText 用语的硬约束
|
||
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
|
||
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
|
||
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
|
||
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
|
||
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
|
||
|
||
七、关于 judgementSummary 的写法
|
||
1. 必须简洁,不要写成长篇分析
|
||
2. 必须直接服务于下一轮正式生成
|
||
3. 最好同时包含两层信息:
|
||
- 为什么这么判断
|
||
- 正式生成时最该优先做什么,或最该避免什么
|
||
|
||
八、硬性约束
|
||
1. 只能输出 JSON,不能输出解释、代码块或额外说明
|
||
2. 不能发明上下文里不存在的设定事实
|
||
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
|
||
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
|
||
5. judgementSummary 必须是中文
|
||
6. 输出值必须严格落在给定枚举中"#;
|
||
|
||
const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
|
||
{
|
||
"userInputSignal": "normal",
|
||
"driftRisk": "low",
|
||
"conversationMode": "expand",
|
||
"judgementSummary": ""
|
||
}"#;
|
||
|
||
const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
|
||
{
|
||
"replyText": "",
|
||
"progressPercent": 0,
|
||
"nextAnchorContent": {
|
||
"worldPromise": {
|
||
"hook": "",
|
||
"differentiator": "",
|
||
"desiredExperience": ""
|
||
},
|
||
"playerFantasy": {
|
||
"playerRole": "",
|
||
"corePursuit": "",
|
||
"fearOfLoss": ""
|
||
},
|
||
"themeBoundary": {
|
||
"toneKeywords": [],
|
||
"aestheticDirectives": [],
|
||
"forbiddenDirectives": []
|
||
},
|
||
"playerEntryPoint": {
|
||
"openingIdentity": "",
|
||
"openingProblem": "",
|
||
"entryMotivation": ""
|
||
},
|
||
"coreConflict": {
|
||
"surfaceConflicts": [],
|
||
"hiddenCrisis": "",
|
||
"firstTouchedConflict": ""
|
||
},
|
||
"keyRelationships": [
|
||
{
|
||
"pairs": "",
|
||
"relationshipType": "",
|
||
"secretOrCost": ""
|
||
}
|
||
],
|
||
"hiddenLines": {
|
||
"hiddenTruths": [],
|
||
"misdirectionHints": [],
|
||
"revealPacing": ""
|
||
},
|
||
"iconicElements": {
|
||
"iconicMotifs": [],
|
||
"institutionsOrArtifacts": [],
|
||
"hardRules": []
|
||
}
|
||
}
|
||
}"#;
|
||
|
||
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,
|
||
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 render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
|
||
format!(
|
||
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
|
||
dynamic_state.user_input_signal.as_str(),
|
||
dynamic_state.drift_risk.as_str(),
|
||
dynamic_state.conversation_mode.as_str(),
|
||
dynamic_state.judgement_summary
|
||
)
|
||
}
|
||
|
||
fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
|
||
format!(
|
||
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
|
||
serde_json::to_string_pretty(anchor_content)
|
||
.unwrap_or_else(|_| empty_agent_anchor_content_json())
|
||
)
|
||
}
|
||
|
||
fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
|
||
format!(
|
||
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
|
||
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
|
||
)
|
||
}
|
||
|
||
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
|
||
let trimmed = text.trim();
|
||
if let Some(start) = trimmed.find('{')
|
||
&& let Some(end) = trimmed.rfind('}')
|
||
&& end > start
|
||
{
|
||
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
|
||
}
|
||
serde_json::from_str::<JsonValue>(trimmed)
|
||
}
|
||
|
||
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
|
||
let key_index = text.find("\"replyText\"")?;
|
||
let colon_index = text[key_index..].find(':')? + key_index;
|
||
let mut cursor = colon_index + 1;
|
||
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
|
||
cursor += 1;
|
||
}
|
||
if text.as_bytes().get(cursor).copied() != Some(b'"') {
|
||
return None;
|
||
}
|
||
cursor += 1;
|
||
let mut decoded = String::new();
|
||
let remainder = text.get(cursor..)?;
|
||
let mut characters = remainder.chars().peekable();
|
||
while let Some(current) = characters.next() {
|
||
if current == '"' {
|
||
return Some(decoded);
|
||
}
|
||
if current == '\\' {
|
||
let escaped = characters.next()?;
|
||
match escaped {
|
||
'"' => decoded.push('"'),
|
||
'\\' => decoded.push('\\'),
|
||
'/' => decoded.push('/'),
|
||
'b' => decoded.push('\u{0008}'),
|
||
'f' => decoded.push('\u{000C}'),
|
||
'n' => decoded.push('\n'),
|
||
'r' => decoded.push('\r'),
|
||
't' => decoded.push('\t'),
|
||
'u' => {
|
||
let mut hex = String::new();
|
||
for _ in 0..4 {
|
||
hex.push(characters.next()?);
|
||
}
|
||
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
|
||
&& let Some(character) = char::from_u32(code as u32)
|
||
{
|
||
decoded.push(character);
|
||
}
|
||
}
|
||
other => decoded.push(other),
|
||
}
|
||
continue;
|
||
}
|
||
decoded.push(current);
|
||
}
|
||
Some(decoded)
|
||
}
|
||
|
||
fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
|
||
match value.and_then(JsonValue::as_str)? {
|
||
"rich" => Some(PromptUserInputSignal::Rich),
|
||
"normal" => Some(PromptUserInputSignal::Normal),
|
||
"sparse" => Some(PromptUserInputSignal::Sparse),
|
||
"correction" => Some(PromptUserInputSignal::Correction),
|
||
"delegate" => Some(PromptUserInputSignal::Delegate),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
|
||
match value.and_then(JsonValue::as_str)? {
|
||
"low" => Some(PromptDriftRisk::Low),
|
||
"medium" => Some(PromptDriftRisk::Medium),
|
||
"high" => Some(PromptDriftRisk::High),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
|
||
match value.and_then(JsonValue::as_str)? {
|
||
"bootstrap" => Some(PromptConversationMode::Bootstrap),
|
||
"expand" => Some(PromptConversationMode::Expand),
|
||
"compress" => Some(PromptConversationMode::Compress),
|
||
"repair_direction" => Some(PromptConversationMode::RepairDirection),
|
||
"force_complete" => Some(PromptConversationMode::ForceComplete),
|
||
"closing" => Some(PromptConversationMode::Closing),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||
match mode {
|
||
PromptConversationMode::Bootstrap => {
|
||
r#"当前模式:bootstrap
|
||
|
||
目标:
|
||
1. 先把世界的基本方向抓住
|
||
2. 不要一次塞太多新设定
|
||
3. 回复要降低用户开口压力
|
||
|
||
本轮行为要求:
|
||
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
|
||
2. 如果用户信息很少,不要强行把整套结构一次补满
|
||
3. replyText 要像共创搭档,而不是像审问
|
||
4. 默认只推进一个最关键的问题方向
|
||
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
|
||
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
|
||
7. 不要把问题问得像表单采集,不要一口气追问多个维度
|
||
|
||
用户体验要求:
|
||
1. 让用户觉得“现在很容易继续往下说”
|
||
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
|
||
3. replyText 最好短、稳、可接话
|
||
4. 如果用户信息很少,也不要显得冷淡或机械"#
|
||
}
|
||
PromptConversationMode::Expand => {
|
||
r#"当前模式:expand
|
||
|
||
目标:
|
||
1. 在保持现有方向的前提下,把设定结构逐步补全
|
||
2. 尽量让一轮输入覆盖多个关键维度
|
||
|
||
本轮行为要求:
|
||
1. 继续保留上一版里仍成立的设定
|
||
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
|
||
3. replyText 要明确体现“你已经理解了哪些内容”
|
||
4. 不要突然大幅改写已经成形的世界
|
||
5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步
|
||
6. 可以适度替用户整理,但不要把回复写成总结报告
|
||
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
|
||
|
||
用户体验要求:
|
||
1. 让用户感到“我刚说的内容都被接住了”
|
||
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
|
||
3. 不要无视用户刚提供的高价值细节
|
||
4. 不要让用户觉得系统在自顾自重写世界"#
|
||
}
|
||
PromptConversationMode::Compress => {
|
||
r#"当前模式:compress
|
||
|
||
目标:
|
||
1. 开始收束当前设定
|
||
2. 减少无效发散
|
||
3. 让 progress 更接近可进入下一阶段
|
||
|
||
本轮行为要求:
|
||
1. 新的设定结构优先保留稳定内容,不要无端重写
|
||
2. 对用户本轮输入做高密度吸收
|
||
3. replyText 要更聚焦,不要绕圈
|
||
4. 默认只推进当前最影响 completion 的一步
|
||
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
|
||
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
|
||
7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问
|
||
|
||
用户体验要求:
|
||
1. 让用户感觉世界正在变得更稳,而不是越来越散
|
||
2. 让推进感更明确,但不要显得催促
|
||
3. 回复语气应更笃定一些,减少反复横跳
|
||
4. 不要把用户刚补进来的细节又冲淡掉"#
|
||
}
|
||
PromptConversationMode::RepairDirection => {
|
||
r#"当前模式:repair_direction
|
||
|
||
目标:
|
||
1. 处理用户对既有设定的修正
|
||
2. 避免世界方向飘散或自相矛盾
|
||
|
||
本轮行为要求:
|
||
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
|
||
2. 对已经不再成立的旧设定,不要机械保留
|
||
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
|
||
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
|
||
5. 先处理“改掉什么”,再决定“往哪里继续推”
|
||
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
|
||
7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境
|
||
|
||
用户体验要求:
|
||
1. 让用户感到“我刚刚的纠偏真的生效了”
|
||
2. 不要和用户辩论旧方案为什么也行
|
||
3. 不要表现出对修正的不情愿
|
||
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
|
||
}
|
||
PromptConversationMode::ForceComplete => {
|
||
r#"当前模式:force_complete
|
||
|
||
目标:
|
||
1. 基于当前方向直接补齐剩余设定
|
||
2. 生成一版尽量完整、可进入下一阶段的设定结构
|
||
3. 结束当前收集阶段
|
||
|
||
本轮行为要求:
|
||
1. 尽量保留已经形成的世界方向
|
||
2. 对明显缺失的关键维度进行合理补全
|
||
3. 不要继续拉长聊天,不要再追问用户
|
||
4. progressPercent 直接输出为 100
|
||
5. replyText 要自然引导用户点击“生成游戏设定草稿”
|
||
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
|
||
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
|
||
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
|
||
|
||
用户体验要求:
|
||
1. 让用户感到“系统已经帮我把能补的补好了”
|
||
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
|
||
3. 回复要有完成感,但不要太官话
|
||
4. 清楚告诉用户下一步可以做什么"#
|
||
}
|
||
PromptConversationMode::Closing => {
|
||
r#"当前模式:closing
|
||
|
||
目标:
|
||
1. 尽量形成一版可用的设定底子
|
||
2. 不再继续发散新世界观
|
||
|
||
本轮行为要求:
|
||
1. 优先收束,而不是扩写
|
||
2. 不要大改已经成形的核心设定
|
||
3. progressPercent 接近完成时,replyText 要更像确认与推进
|
||
4. 如果用户没有大改方向,尽量让下一版内容更稳定
|
||
5. 可以轻微补足缺口,但不要再大开新支线
|
||
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
|
||
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
|
||
|
||
用户体验要求:
|
||
1. 让用户感觉作品已经快成了,而不是还在无穷试探
|
||
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
|
||
3. 保持留白感,不要把所有东西都一次说死
|
||
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
|
||
}
|
||
}
|
||
}
|
||
|
||
fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
|
||
match signal {
|
||
PromptUserInputSignal::Rich => {
|
||
r#"本轮用户输入信息密度高。
|
||
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
|
||
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
|
||
}
|
||
PromptUserInputSignal::Normal => {
|
||
r#"本轮用户输入为正常补充。
|
||
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
|
||
}
|
||
PromptUserInputSignal::Sparse => {
|
||
r#"本轮用户输入较少或较虚。
|
||
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
|
||
replyText 要让用户容易继续往下说。"#
|
||
}
|
||
PromptUserInputSignal::Correction => {
|
||
r#"本轮用户在修正或推翻旧设定。
|
||
请优先吸收修正,不要机械复读旧版本。
|
||
新的完整设定结构必须以修正后的方向为准。"#
|
||
}
|
||
PromptUserInputSignal::Delegate => {
|
||
r#"本轮用户把部分决定权交给你。
|
||
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
|
||
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
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 {
|
||
fn as_str(self) -> &'static str {
|
||
match self {
|
||
Self::Low => "low",
|
||
Self::Medium => "medium",
|
||
Self::High => "high",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl PromptConversationMode {
|
||
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 super::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("你好,潮雾列岛"));
|
||
}
|
||
}
|