65d1489bdd
新增 Anthropic 链路,LLM 请求收口为 LlmRunRequest/LlmApiKind,为未来创建更多字段适配高级功能准备 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/70 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
307 lines
11 KiB
Rust
307 lines
11 KiB
Rust
use module_big_fish::{
|
|
BIG_FISH_MAX_LEVEL_COUNT, BIG_FISH_MIN_LEVEL_COUNT, BigFishAnchorPack, BigFishGameDraft,
|
|
BigFishLevelBlueprint, BigFishRuntimeParams, compile_default_draft,
|
|
};
|
|
use platform_llm::{LlmClient, LlmMessage, LlmRunRequest};
|
|
use serde::Deserialize;
|
|
use serde_json::Value as JsonValue;
|
|
|
|
use crate::creation_agent_llm_turn::parse_json_response_text;
|
|
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
|
|
|
|
const BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT: &str = r#"你是一个负责把“大鱼吃小鱼”玩法锚点编译成首版可实现草稿的中文玩法策划。
|
|
|
|
你必须直接输出单个 JSON 对象,不要输出 Markdown、代码块、解释、前言或补充说明。
|
|
|
|
硬约束:
|
|
1. 所有文案必须是中文。
|
|
2. 必须产出 6 到 12 级的连续等级阶梯,默认优先 8 级。
|
|
3. 每一级都必须有:name、oneLineFantasy、textDescription、visualDescription、idleMotionDescription、moveMotionDescription。
|
|
4. 每一级都必须体现等级递进关系:越高等级越大、越强、越有压迫感。
|
|
5. `visualPromptSeed` 必须能直接作为主图默认提示词。
|
|
6. `motionPromptSeed` 必须是该等级动作方向总提示词摘要,但不能替代具体 idle / move 描述。
|
|
7. `preyWindow` 和 `threatWindow` 必须是合法等级数组,围绕当前等级形成可玩窗口。
|
|
8. `background` 必须是竖屏 9:16 游戏背景口径,不出现主体和 UI。
|
|
9. `runtimeParams.levelCount` 必须与 levels 长度一致,`winLevel` 必须等于最高等级。
|
|
"#;
|
|
|
|
const BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT: &str = "你是 JSON 修复器。\n你会收到一段本应为单个 JSON 对象的文本。\n你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。\n不要输出 Markdown、代码块、解释、注释或额外文字。";
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct BigFishDraftCompileError(String);
|
|
|
|
impl BigFishDraftCompileError {
|
|
fn new(message: impl Into<String>) -> Self {
|
|
Self(message.into())
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for BigFishDraftCompileError {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
formatter.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for BigFishDraftCompileError {}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BigFishDraftModelOutput {
|
|
title: String,
|
|
subtitle: String,
|
|
core_fun: String,
|
|
ecology_theme: String,
|
|
levels: Vec<BigFishLevelBlueprint>,
|
|
background: module_big_fish::BigFishBackgroundBlueprint,
|
|
runtime_params: BigFishRuntimeParams,
|
|
}
|
|
|
|
pub(crate) async fn compile_big_fish_draft_with_fallback(
|
|
llm_client: Option<&LlmClient>,
|
|
anchor_pack: &BigFishAnchorPack,
|
|
) -> BigFishGameDraft {
|
|
let fallback = compile_default_draft(anchor_pack);
|
|
let Some(llm_client) = llm_client else {
|
|
return fallback;
|
|
};
|
|
|
|
match request_big_fish_draft(llm_client, anchor_pack).await {
|
|
Ok(draft) => draft,
|
|
Err(error) => {
|
|
tracing::warn!(error = %error, "大鱼吃小鱼草稿结构化编译失败,回退到 deterministic fallback");
|
|
fallback
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn request_big_fish_draft(
|
|
llm_client: &LlmClient,
|
|
anchor_pack: &BigFishAnchorPack,
|
|
) -> Result<BigFishGameDraft, BigFishDraftCompileError> {
|
|
let user_prompt = build_big_fish_draft_user_prompt(anchor_pack);
|
|
let parsed = request_big_fish_json_stage(
|
|
llm_client,
|
|
user_prompt,
|
|
"big-fish-draft-compile",
|
|
"大鱼吃小鱼草稿编译没有返回有效内容。",
|
|
)
|
|
.await?;
|
|
let output: BigFishDraftModelOutput = serde_json::from_value(parsed).map_err(|error| {
|
|
BigFishDraftCompileError::new(format!("大鱼吃小鱼草稿 JSON 结构非法:{error}"))
|
|
})?;
|
|
validate_big_fish_draft_output(&output)?;
|
|
|
|
Ok(BigFishGameDraft {
|
|
title: output.title,
|
|
subtitle: output.subtitle,
|
|
core_fun: output.core_fun,
|
|
ecology_theme: output.ecology_theme,
|
|
levels: output.levels,
|
|
background: output.background,
|
|
runtime_params: output.runtime_params,
|
|
})
|
|
}
|
|
|
|
async fn request_big_fish_json_stage(
|
|
llm_client: &LlmClient,
|
|
user_prompt: String,
|
|
debug_label: &str,
|
|
empty_response_message: &str,
|
|
) -> Result<JsonValue, BigFishDraftCompileError> {
|
|
let response = llm_client
|
|
.run(
|
|
LlmRunRequest::new(vec![
|
|
LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT),
|
|
LlmMessage::user(user_prompt),
|
|
])
|
|
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
|
.with_openai_responses()
|
|
.with_web_search(true),
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
BigFishDraftCompileError::new(format!("{debug_label} LLM 请求失败:{error}"))
|
|
})?;
|
|
let text = response.text.trim();
|
|
if text.is_empty() {
|
|
return Err(BigFishDraftCompileError::new(empty_response_message));
|
|
}
|
|
match parse_json_response_text(text) {
|
|
Ok(value) => Ok(value),
|
|
Err(_) => {
|
|
let repaired = llm_client
|
|
.run(
|
|
LlmRunRequest::new(vec![
|
|
LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT),
|
|
LlmMessage::user(format!(
|
|
"请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}"
|
|
)),
|
|
])
|
|
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
|
.with_openai_responses(),
|
|
)
|
|
.await
|
|
.map_err(|error| {
|
|
BigFishDraftCompileError::new(format!(
|
|
"{debug_label} JSON 修复请求失败:{error}"
|
|
))
|
|
})?;
|
|
parse_json_response_text(repaired.text.as_str()).map_err(|error| {
|
|
BigFishDraftCompileError::new(format!("{debug_label} JSON 解析失败:{error}"))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_big_fish_draft_user_prompt(anchor_pack: &BigFishAnchorPack) -> String {
|
|
format!(
|
|
r#"请基于下面的大鱼吃小鱼玩法锚点,直接生成首版结果页草稿。
|
|
|
|
玩法承诺:{gameplay_promise}
|
|
生态与视觉母题:{ecology_visual_theme}
|
|
成长阶梯:{growth_ladder}
|
|
风险节奏:{risk_tempo}
|
|
|
|
请严格输出下列 JSON 结构:
|
|
{{
|
|
"title": "",
|
|
"subtitle": "",
|
|
"coreFun": "",
|
|
"ecologyTheme": "",
|
|
"levels": [
|
|
{{
|
|
"level": 1,
|
|
"name": "",
|
|
"oneLineFantasy": "",
|
|
"textDescription": "",
|
|
"silhouetteDirection": "",
|
|
"sizeRatio": 1.0,
|
|
"visualDescription": "",
|
|
"visualPromptSeed": "",
|
|
"idleMotionDescription": "",
|
|
"moveMotionDescription": "",
|
|
"motionPromptSeed": "",
|
|
"mergeSourceLevel": null,
|
|
"preyWindow": [1],
|
|
"threatWindow": [2],
|
|
"isFinalLevel": false
|
|
}}
|
|
],
|
|
"background": {{
|
|
"theme": "",
|
|
"colorMood": "",
|
|
"foregroundHints": "",
|
|
"midgroundComposition": "",
|
|
"backgroundDepth": "",
|
|
"safePlayAreaHint": "",
|
|
"spawnEdgeHint": "",
|
|
"backgroundPromptSeed": ""
|
|
}},
|
|
"runtimeParams": {{
|
|
"levelCount": 8,
|
|
"mergeCountPerUpgrade": 3,
|
|
"spawnTargetCount": 12,
|
|
"leaderMoveSpeed": 160,
|
|
"followerCatchUpSpeed": 120,
|
|
"offscreenCullSeconds": 3,
|
|
"preySpawnDeltaLevels": [1, 2],
|
|
"threatSpawnDeltaLevels": [1, 2],
|
|
"winLevel": 8
|
|
}}
|
|
}}
|
|
|
|
补充要求:
|
|
1. `title`、`subtitle`、`coreFun` 必须适合结果页直接展示。
|
|
2. 每一级 `textDescription` 要解释这一等级在成长链中的定位。
|
|
3. `visualDescription` 要能直接填入主图工坊。
|
|
4. `idleMotionDescription` 和 `moveMotionDescription` 要分别对应待机动作与移动动作工坊。
|
|
5. `visualPromptSeed` 必须是主图生成用的中文提示词,不要只写关键词。
|
|
6. `motionPromptSeed` 必须是该等级动作生成的总提示词摘要,要同时覆盖待机和移动方向。
|
|
7. 如果锚点没有明确等级数量,默认输出 8 级。
|
|
"#,
|
|
gameplay_promise = anchor_pack.gameplay_promise.value,
|
|
ecology_visual_theme = anchor_pack.ecology_visual_theme.value,
|
|
growth_ladder = anchor_pack.growth_ladder.value,
|
|
risk_tempo = anchor_pack.risk_tempo.value,
|
|
)
|
|
}
|
|
|
|
fn validate_big_fish_draft_output(
|
|
output: &BigFishDraftModelOutput,
|
|
) -> Result<(), BigFishDraftCompileError> {
|
|
let level_count = output.levels.len() as u32;
|
|
if !(BIG_FISH_MIN_LEVEL_COUNT..=BIG_FISH_MAX_LEVEL_COUNT).contains(&level_count) {
|
|
return Err(BigFishDraftCompileError::new(format!(
|
|
"大鱼吃小鱼草稿等级数非法:{level_count}"
|
|
)));
|
|
}
|
|
if output.runtime_params.level_count != level_count {
|
|
return Err(BigFishDraftCompileError::new(
|
|
"runtimeParams.levelCount 必须与 levels 数量一致",
|
|
));
|
|
}
|
|
if output.runtime_params.win_level != level_count {
|
|
return Err(BigFishDraftCompileError::new(
|
|
"runtimeParams.winLevel 必须等于最高等级",
|
|
));
|
|
}
|
|
|
|
for (index, level) in output.levels.iter().enumerate() {
|
|
let expected_level = (index + 1) as u32;
|
|
if level.level != expected_level {
|
|
return Err(BigFishDraftCompileError::new(format!(
|
|
"等级序号不连续:期望 {expected_level},实际 {}",
|
|
level.level
|
|
)));
|
|
}
|
|
ensure_non_empty(level.name.as_str(), "level.name")?;
|
|
ensure_non_empty(level.one_line_fantasy.as_str(), "level.oneLineFantasy")?;
|
|
ensure_non_empty(level.text_description.as_str(), "level.textDescription")?;
|
|
ensure_non_empty(level.visual_description.as_str(), "level.visualDescription")?;
|
|
ensure_non_empty(
|
|
level.idle_motion_description.as_str(),
|
|
"level.idleMotionDescription",
|
|
)?;
|
|
ensure_non_empty(
|
|
level.move_motion_description.as_str(),
|
|
"level.moveMotionDescription",
|
|
)?;
|
|
ensure_non_empty(level.visual_prompt_seed.as_str(), "level.visualPromptSeed")?;
|
|
ensure_non_empty(level.motion_prompt_seed.as_str(), "level.motionPromptSeed")?;
|
|
}
|
|
|
|
ensure_non_empty(output.title.as_str(), "title")?;
|
|
ensure_non_empty(output.subtitle.as_str(), "subtitle")?;
|
|
ensure_non_empty(output.core_fun.as_str(), "coreFun")?;
|
|
ensure_non_empty(output.ecology_theme.as_str(), "ecologyTheme")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_non_empty(value: &str, field_name: &str) -> Result<(), BigFishDraftCompileError> {
|
|
if value.trim().is_empty() {
|
|
return Err(BigFishDraftCompileError::new(format!(
|
|
"{field_name} 不能为空"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use module_big_fish::infer_anchor_pack;
|
|
|
|
use super::build_big_fish_draft_user_prompt;
|
|
|
|
#[test]
|
|
fn big_fish_draft_prompt_requires_all_level_descriptions() {
|
|
let prompt = build_big_fish_draft_user_prompt(&infer_anchor_pack("深海机械鱼", None));
|
|
|
|
assert!(prompt.contains("textDescription"));
|
|
assert!(prompt.contains("visualDescription"));
|
|
assert!(prompt.contains("idleMotionDescription"));
|
|
assert!(prompt.contains("moveMotionDescription"));
|
|
assert!(prompt.contains("visualPromptSeed"));
|
|
assert!(prompt.contains("motionPromptSeed"));
|
|
}
|
|
}
|