1
This commit is contained in:
312
server-rs/crates/api-server/src/big_fish_agent_turn.rs
Normal file
312
server-rs/crates/api-server/src/big_fish_agent_turn.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
use module_big_fish::{BigFishAnchorPack, BigFishCreationStage};
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
|
||||
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,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BigFishAgentTurnRequest<'a> {
|
||||
pub llm_client: Option<&'a LlmClient>,
|
||||
pub session: &'a BigFishSessionRecord,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
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<F>(
|
||||
request: BigFishAgentTurnRequest<'_>,
|
||||
mut on_reply_update: F,
|
||||
) -> Result<BigFishAgentTurnResult, BigFishAgentTurnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let llm_client = request
|
||||
.llm_client
|
||||
.ok_or_else(|| BigFishAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
|
||||
let prompt = build_big_fish_agent_prompt(request.session);
|
||||
let mut latest_reply_text = String::new();
|
||||
let response = llm_client
|
||||
.stream_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{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
|
||||
.map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼聊天生成失败,请稍后重试。"))?;
|
||||
|
||||
let parsed = parse_json_response_text(response.content.as_str())
|
||||
.map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果解析失败,请稍后重试。"))?;
|
||||
let output = parse_big_fish_model_output(&parsed)?;
|
||||
if output.reply_text != latest_reply_text {
|
||||
on_reply_update(output.reply_text.as_str());
|
||||
}
|
||||
|
||||
Ok(BigFishAgentTurnResult {
|
||||
assistant_reply_text: output.reply_text,
|
||||
stage: BigFishCreationStage::CollectingAnchors.as_str().to_string(),
|
||||
progress_percent: 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) -> String {
|
||||
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
|
||||
.map(render_anchor_question_block)
|
||||
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
|
||||
format!(
|
||||
"{anchor_question_block}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
|
||||
anchor_question_block = anchor_question_block,
|
||||
turn = session.current_turn.saturating_add(1),
|
||||
progress = session.progress_percent,
|
||||
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<JsonValue> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
json!({
|
||||
"role": message.role,
|
||||
"kind": message.kind,
|
||||
"content": message.text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_big_fish_model_output(
|
||||
parsed: &JsonValue,
|
||||
) -> Result<BigFishAgentModelOutput, BigFishAgentTurnError> {
|
||||
serde_json::from_value::<BigFishAgentModelOutput>(parsed.clone())
|
||||
.map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼模型结果缺少必要内容,请稍后重试。"))
|
||||
}
|
||||
|
||||
fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String {
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"gameplayPromise": {
|
||||
"key": anchor_pack.gameplay_promise.key,
|
||||
"label": anchor_pack.gameplay_promise.label,
|
||||
"value": anchor_pack.gameplay_promise.value,
|
||||
"status": anchor_pack.gameplay_promise.status,
|
||||
},
|
||||
"ecologyVisualTheme": {
|
||||
"key": anchor_pack.ecology_visual_theme.key,
|
||||
"label": anchor_pack.ecology_visual_theme.label,
|
||||
"value": anchor_pack.ecology_visual_theme.value,
|
||||
"status": anchor_pack.ecology_visual_theme.status,
|
||||
},
|
||||
"growthLadder": {
|
||||
"key": anchor_pack.growth_ladder.key,
|
||||
"label": anchor_pack.growth_ladder.label,
|
||||
"value": anchor_pack.growth_ladder.value,
|
||||
"status": anchor_pack.growth_ladder.status,
|
||||
},
|
||||
"riskTempo": {
|
||||
"key": anchor_pack.risk_tempo.key,
|
||||
"label": anchor_pack.risk_tempo.label,
|
||||
"value": anchor_pack.risk_tempo.value,
|
||||
"status": anchor_pack.risk_tempo.status,
|
||||
},
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
|
||||
if let Ok(value) = serde_json::from_str::<JsonValue>(text) {
|
||||
return Ok(value);
|
||||
}
|
||||
let Some(start) = text.find('{') else {
|
||||
return serde_json::from_str(text);
|
||||
};
|
||||
let Some(end) = text.rfind('}') else {
|
||||
return serde_json::from_str(text);
|
||||
};
|
||||
serde_json::from_str(&text[start..=end])
|
||||
}
|
||||
|
||||
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
|
||||
let marker = "\"replyText\"";
|
||||
let marker_index = text.find(marker)?;
|
||||
let after_marker = &text[marker_index + marker.len()..];
|
||||
let colon_index = after_marker.find(':')?;
|
||||
let after_colon = after_marker[colon_index + 1..].trim_start();
|
||||
let content = after_colon.strip_prefix('"')?;
|
||||
let mut result = String::new();
|
||||
let mut escaped = false;
|
||||
for character in content.chars() {
|
||||
if escaped {
|
||||
result.push(match character {
|
||||
'n' => '\n',
|
||||
'r' => '\r',
|
||||
't' => '\t',
|
||||
'"' => '"',
|
||||
'\\' => '\\',
|
||||
other => other,
|
||||
});
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if character == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if character == '"' {
|
||||
return Some(result);
|
||||
}
|
||||
result.push(character);
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user