303 lines
10 KiB
Rust
303 lines
10 KiB
Rust
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<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct BigFishAgentTurnError {
|
||
message: String,
|
||
}
|
||
|
||
impl BigFishAgentTurnError {
|
||
fn new(message: impl Into<String>) -> 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<F>(
|
||
request: BigFishAgentTurnRequest<'_>,
|
||
on_reply_update: F,
|
||
) -> Result<BigFishAgentTurnResult, BigFishAgentTurnError>
|
||
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<BigFishAgentModelOutput, BigFishAgentTurnError> {
|
||
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<BigFishAnchorPack, BigFishAgentTurnError> {
|
||
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<module_big_fish::BigFishAnchorItem, BigFishAgentTurnError> {
|
||
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"));
|
||
}
|
||
}
|