use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage}; use platform_llm::LlmClient; use serde::{Deserialize, Serialize}; use serde_json::{Value as JsonValue, json}; use spacetime_client::{ BigFishAgentMessageRecord, BigFishMessageFinalizeRecordInput, BigFishSessionRecord, }; use crate::creation_agent_anchor_templates::{ get_creation_agent_anchor_template, render_anchor_question_block, }; use crate::creation_agent_chat::render_quick_fill_extra_rules; use crate::creation_agent_llm_turn::{ CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn, }; #[derive(Clone, Debug)] pub(crate) struct BigFishAgentTurnRequest<'a> { pub llm_client: Option<&'a LlmClient>, pub session: &'a BigFishSessionRecord, pub quick_fill_requested: bool, } #[derive(Clone, Debug)] pub(crate) struct BigFishAgentTurnResult { pub assistant_reply_text: String, pub stage: String, pub progress_percent: u32, pub anchor_pack_json: String, pub error_message: Option, } #[derive(Clone, Debug)] pub(crate) struct BigFishAgentTurnError { message: String, } impl BigFishAgentTurnError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl std::fmt::Display for BigFishAgentTurnError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(&self.message) } } impl std::error::Error for BigFishAgentTurnError {} #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct BigFishAgentModelOutput { reply_text: String, progress_percent: u32, next_anchor_pack: BigFishAnchorPack, } const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。 你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。 你必须同时输出: 1. 一段直接发给用户的中文回复 replyText 2. 当前进度 progressPercent 3. 下一轮完整可用的 nextAnchorPack 硬约束: 1. 只能输出 JSON,不能输出代码块或解释 2. nextAnchorPack 必须是完整对象,不能只输出 patch 3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词 4. replyText 一次最多推进一个最关键问题 5. 必须对齐 RPG 共创的体验:先理解玩家幻想,再收束成能进入运行时的可玩效果 6. progressPercent 范围只能是 0 到 100 7. status 只能使用 missing / inferred / confirmed / locked "#; const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字: { "replyText": "", "progressPercent": 0, "nextAnchorPack": { "gameplayPromise": { "key": "gameplayPromise", "label": "玩法承诺", "value": "", "status": "missing" }, "ecologyVisualTheme": { "key": "ecologyVisualTheme", "label": "生态视觉主题", "value": "", "status": "missing" }, "growthLadder": { "key": "growthLadder", "label": "成长阶梯", "value": "", "status": "missing" }, "riskTempo": { "key": "riskTempo", "label": "风险节奏", "value": "", "status": "missing" } } }"#; pub(crate) async fn run_big_fish_agent_turn( request: BigFishAgentTurnRequest<'_>, on_reply_update: F, ) -> Result where F: FnMut(&str), { let prompt = build_big_fish_agent_prompt(request.session, request.quick_fill_requested); let turn_output = stream_creation_agent_json_turn( request.llm_client, format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}"), "请按约定输出这一轮的 JSON。", CreationAgentLlmTurnErrorMessages { model_unavailable: "当前模型不可用,请稍后重试。", generation_failed: "大鱼吃小鱼聊天生成失败,请稍后重试。", parse_failed: "大鱼吃小鱼聊天结果解析失败,请稍后重试。", }, on_reply_update, BigFishAgentTurnError::new, ) .await?; let output = parse_big_fish_model_output(&turn_output.parsed)?; Ok(BigFishAgentTurnResult { assistant_reply_text: output.reply_text, stage: BigFishCreationStage::CollectingAnchors.as_str().to_string(), progress_percent: if request.quick_fill_requested { 100 } else { output.progress_percent.min(100) }, anchor_pack_json: serde_json::to_string(&output.next_anchor_pack) .unwrap_or_else(|_| "{}".to_string()), error_message: None, }) } pub(crate) fn build_finalize_record_input( session_id: String, owner_user_id: String, assistant_message_id: String, result: BigFishAgentTurnResult, updated_at_micros: i64, ) -> BigFishMessageFinalizeRecordInput { BigFishMessageFinalizeRecordInput { session_id, owner_user_id, assistant_message_id: Some(assistant_message_id), assistant_reply_text: Some(result.assistant_reply_text), stage: result.stage, progress_percent: result.progress_percent, anchor_pack_json: result.anchor_pack_json, error_message: result.error_message, updated_at_micros, } } pub(crate) fn build_failed_finalize_record_input( session_id: String, owner_user_id: String, session: &BigFishSessionRecord, error_message: String, updated_at_micros: i64, ) -> BigFishMessageFinalizeRecordInput { BigFishMessageFinalizeRecordInput { session_id, owner_user_id, assistant_message_id: None, assistant_reply_text: None, stage: session.stage.clone(), progress_percent: session.progress_percent, anchor_pack_json: serialize_record_anchor_pack(&session.anchor_pack), error_message: Some(error_message), updated_at_micros, } } fn build_big_fish_agent_prompt( session: &BigFishSessionRecord, quick_fill_requested: bool, ) -> String { let anchor_question_block = get_creation_agent_anchor_template("big_fish") .map(render_anchor_question_block) .unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string()); let quick_fill_rules = if quick_fill_requested { format!( "\n\n{}", render_quick_fill_extra_rules( "当前玩法方向里的成长、生态、风险节奏等缺失关键词", "不要要求用户再提供等级、鱼群、场景或节奏信息", "输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项", "生成结果页", ) ) } else { String::new() }; format!( "{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", anchor_question_block = anchor_question_block, quick_fill_rules = quick_fill_rules, turn = session.current_turn.saturating_add(1), progress = session.progress_percent, quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" }, anchor_pack = serialize_record_anchor_pack(&session.anchor_pack), chat_history = serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice())) .unwrap_or_else(|_| "[]".to_string()), contract = BIG_FISH_AGENT_OUTPUT_CONTRACT, ) } fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec { messages .iter() .map(|message| { json!({ "role": message.role, "kind": message.kind, "content": message.text, }) }) .collect() } fn parse_big_fish_model_output( parsed: &JsonValue, ) -> Result { let reply_text = parsed .get("replyText") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少有效回复,请稍后重试。"))? .to_string(); let progress_percent = parsed .get("progressPercent") .and_then(JsonValue::as_u64) .map(|value| value.min(100) as u32) .unwrap_or(0); let next_anchor_pack_value = parsed .get("nextAnchorPack") .cloned() .ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少 nextAnchorPack。"))?; let next_anchor_pack = parse_big_fish_model_anchor_pack(&next_anchor_pack_value)?; Ok(BigFishAgentModelOutput { reply_text, progress_percent, next_anchor_pack, }) } fn parse_big_fish_model_anchor_pack( value: &JsonValue, ) -> Result { Ok(BigFishAnchorPack { // LLM 与 HTTP 契约使用 camelCase;SpacetimeDB 持久化结构保持 Rust snake_case,边界处必须显式翻译。 gameplay_promise: parse_big_fish_model_anchor_item(value, "gameplayPromise")?, ecology_visual_theme: parse_big_fish_model_anchor_item(value, "ecologyVisualTheme")?, growth_ladder: parse_big_fish_model_anchor_item(value, "growthLadder")?, risk_tempo: parse_big_fish_model_anchor_item(value, "riskTempo")?, }) } fn parse_big_fish_model_anchor_item( pack: &JsonValue, field_name: &str, ) -> Result { let value = pack.get(field_name).ok_or_else(|| { BigFishAgentTurnError::new(format!("大鱼吃小鱼 anchor pack 缺少 {field_name}。")) })?; let key = value .get("key") .and_then(JsonValue::as_str) .map(str::trim) .filter(|text| !text.is_empty()) .unwrap_or(field_name) .to_string(); let label = value .get("label") .and_then(JsonValue::as_str) .map(str::trim) .filter(|text| !text.is_empty()) .unwrap_or_else(|| default_big_fish_anchor_label(field_name)) .to_string(); let item_value = value .get("value") .and_then(JsonValue::as_str) .map(str::trim) .unwrap_or_default() .to_string(); let status = value .get("status") .and_then(JsonValue::as_str) .map(parse_big_fish_anchor_status) .unwrap_or(BigFishAnchorStatus::Missing); Ok(module_big_fish::BigFishAnchorItem { key, label, value: item_value, status, }) } fn default_big_fish_anchor_label(field_name: &str) -> &'static str { match field_name { "gameplayPromise" => "玩法承诺", "ecologyVisualTheme" => "生态视觉主题", "growthLadder" => "成长阶梯", "riskTempo" => "风险节奏", _ => "大鱼锚点", } } fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String { serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack)) .unwrap_or_else(|_| "{}".to_string()) } fn map_big_fish_record_anchor_pack( record: &spacetime_client::BigFishAnchorPackRecord, ) -> BigFishAnchorPack { BigFishAnchorPack { gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise), ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme), growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder), risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo), } } fn map_big_fish_record_anchor_item( record: &spacetime_client::BigFishAnchorItemRecord, ) -> module_big_fish::BigFishAnchorItem { module_big_fish::BigFishAnchorItem { key: record.key.clone(), label: record.label.clone(), value: record.value.clone(), status: parse_big_fish_anchor_status(record.status.as_str()), } } fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus { match value { "confirmed" => BigFishAnchorStatus::Confirmed, "locked" => BigFishAnchorStatus::Locked, "inferred" => BigFishAnchorStatus::Inferred, _ => BigFishAnchorStatus::Missing, } } #[cfg(test)] mod tests { use super::build_big_fish_agent_prompt; fn anchor_item( key: &str, label: &str, value: &str, status: &str, ) -> spacetime_client::BigFishAnchorItemRecord { spacetime_client::BigFishAnchorItemRecord { key: key.to_string(), label: label.to_string(), value: value.to_string(), status: status.to_string(), } } fn empty_session_record() -> spacetime_client::BigFishSessionRecord { spacetime_client::BigFishSessionRecord { session_id: "big-fish-session-test".to_string(), current_turn: 2, progress_percent: 60, stage: "collecting_anchors".to_string(), anchor_pack: spacetime_client::BigFishAnchorPackRecord { gameplay_promise: anchor_item( "gameplayPromise", "玩法承诺", "微光小鱼逆袭深海巨兽", "confirmed", ), ecology_visual_theme: anchor_item( "ecologyVisualTheme", "生态视觉主题", "幽蓝珊瑚海沟", "confirmed", ), growth_ladder: anchor_item("growthLadder", "成长阶梯", "", "missing"), risk_tempo: anchor_item("riskTempo", "风险节奏", "", "missing"), }, draft: None, asset_slots: Vec::new(), asset_coverage: spacetime_client::BigFishAssetCoverageRecord { level_main_image_ready_count: 0, level_motion_ready_count: 0, background_ready: false, required_level_count: 8, publish_ready: false, blockers: Vec::new(), }, messages: Vec::new(), last_assistant_reply: None, publish_ready: false, updated_at: "2026-04-24T10:00:00.000Z".to_string(), } } #[test] fn quick_fill_prompt_forbids_follow_up_questions() { let prompt = build_big_fish_agent_prompt(&empty_session_record(), true); assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字")); assert!(prompt.contains("不要再继续提问")); assert!(prompt.contains("progressPercent 直接输出为 100")); } }