Files
Genarrative/server-rs/crates/api-server/src/big_fish_agent_turn.rs
2026-04-28 19:36:39 +08:00

303 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 契约使用 camelCaseSpacetimeDB 持久化结构保持 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"));
}
}