use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage}; use platform_llm::LlmClient; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use spacetime_client::{BigFishMessageFinalizeRecordInput, BigFishSessionRecord}; use crate::creation_agent_llm_turn::{ CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn, }; use crate::prompt::big_fish::{ BIG_FISH_AGENT_SYSTEM_PROMPT, build_big_fish_agent_prompt, serialize_record_anchor_pack, }; #[derive(Clone, Debug)] pub(crate) struct BigFishAgentTurnRequest<'a> { pub llm_client: Option<&'a LlmClient>, pub session: &'a BigFishSessionRecord, pub quick_fill_requested: bool, pub enable_web_search: 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, } 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。", request.enable_web_search, 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 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 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")); } }