This commit is contained in:
2026-04-30 17:49:07 +08:00
parent 805d6f8cae
commit 9d684cb7b3
615 changed files with 15368 additions and 6172 deletions

View File

@@ -1,18 +1,16 @@
use module_puzzle::{PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use spacetime_client::{
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentSessionRecord,
};
use serde_json::Value as JsonValue;
use spacetime_client::{PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentSessionRecord};
use crate::creation_agent_anchor_templates::{
get_creation_agent_anchor_template, render_anchor_question_block,
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
use crate::prompt::puzzle::agent_chat::{
PUZZLE_AGENT_JSON_TURN_USER_PROMPT, PUZZLE_AGENT_SYSTEM_PROMPT, build_puzzle_agent_prompt,
serialize_puzzle_record_anchor_pack,
};
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnRequest<'a> {
@@ -60,63 +58,6 @@ struct PuzzleAgentModelOutput {
next_anchor_pack: PuzzleAnchorPack,
}
const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创拼图画面的中文创意策划。
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextAnchorPack
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextAnchorPack 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“锚点”“结构”“JSON”等内部词
4. replyText 一次最多推进一个最关键问题
5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问
6. progressPercent 范围只能是 0 到 100
7. status 只能使用 missing / inferred / confirmed / locked
"#;
const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorPack": {
"themePromise": {
"key": "themePromise",
"label": "题材承诺",
"value": "",
"status": "missing"
},
"visualSubject": {
"key": "visualSubject",
"label": "画面主体",
"value": "",
"status": "missing"
},
"visualMood": {
"key": "visualMood",
"label": "视觉气质",
"value": "",
"status": "missing"
},
"compositionHooks": {
"key": "compositionHooks",
"label": "拼图记忆点",
"value": "",
"status": "missing"
},
"tagsAndForbidden": {
"key": "tagsAndForbidden",
"label": "标签与禁忌",
"value": "",
"status": "missing"
}
}
}"#;
pub(crate) async fn run_puzzle_agent_turn<F>(
request: PuzzleAgentTurnRequest<'_>,
on_reply_update: F,
@@ -128,7 +69,7 @@ where
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
"请按约定输出这一轮的 JSON。",
PUZZLE_AGENT_JSON_TURN_USER_PROMPT,
request.enable_web_search,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
@@ -185,10 +126,6 @@ pub(crate) fn build_failed_finalize_record_input(
error_message: String,
updated_at_micros: i64,
) -> PuzzleAgentMessageFinalizeRecordInput {
let anchor_pack_json = serde_json::to_string(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| {
serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())
});
PuzzleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
@@ -196,61 +133,12 @@ pub(crate) fn build_failed_finalize_record_input(
assistant_reply_text: None,
stage: session.stage.clone(),
progress_percent: session.progress_percent,
anchor_pack_json,
anchor_pack_json: serialize_puzzle_record_anchor_pack(&session.anchor_pack),
error_message: Some(error_message),
updated_at_micros,
}
}
fn build_puzzle_agent_prompt(
session: &PuzzleAgentSessionRecord,
quick_fill_requested: bool,
) -> String {
let anchor_question_block = get_creation_agent_anchor_template("puzzle")
.map(render_anchor_question_block)
.unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string());
let quick_fill_rules = if quick_fill_requested {
format!(
"\n\n{}",
render_quick_fill_extra_rules(
"当前题材方向里的拼图关键词",
"不要要求用户再提供素材、风格或禁忌",
"输出完整 nextAnchorPack直接补齐 value 为空或 status 为 missing 的项",
"生成结果页",
)
)
} else {
String::new()
};
format!(
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
anchor_question_block = anchor_question_block,
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
quick_fill_requested_text = if quick_fill_requested { "" } else { "" },
anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| "{}".to_string()),
chat_history =
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = PUZZLE_AGENT_OUTPUT_CONTRACT,
)
}
fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
fn parse_model_output(parsed: &JsonValue) -> Result<PuzzleAgentModelOutput, PuzzleAgentTurnError> {
let reply_text = parsed
.get("replyText")
@@ -348,27 +236,6 @@ fn resolve_puzzle_agent_stage(progress_percent: u32) -> PuzzleAgentStage {
}
}
fn map_record_anchor_pack(record: &spacetime_client::PuzzleAnchorPackRecord) -> PuzzleAnchorPack {
PuzzleAnchorPack {
theme_promise: map_record_anchor_item(&record.theme_promise),
visual_subject: map_record_anchor_item(&record.visual_subject),
visual_mood: map_record_anchor_item(&record.visual_mood),
composition_hooks: map_record_anchor_item(&record.composition_hooks),
tags_and_forbidden: map_record_anchor_item(&record.tags_and_forbidden),
}
}
fn map_record_anchor_item(
record: &spacetime_client::PuzzleAnchorItemRecord,
) -> module_puzzle::PuzzleAnchorItem {
module_puzzle::PuzzleAnchorItem {
key: record.key.clone(),
label: record.label.clone(),
value: record.value.clone(),
status: parse_anchor_status(record.status.as_str()),
}
}
fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus {
match value {
"confirmed" => PuzzleAnchorStatus::Confirmed,
@@ -383,57 +250,9 @@ mod tests {
use module_puzzle::PuzzleAnchorStatus;
use serde_json::json;
use super::{build_puzzle_agent_prompt, parse_model_output};
use super::parse_model_output;
use crate::creation_agent_llm_turn::extract_reply_text_from_partial_json;
fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord {
spacetime_client::PuzzleAgentSessionRecord {
session_id: "puzzle-session-test".to_string(),
current_turn: 2,
progress_percent: 60,
stage: "collecting_anchors".to_string(),
anchor_pack: spacetime_client::PuzzleAnchorPackRecord {
theme_promise: spacetime_client::PuzzleAnchorItemRecord {
key: "themePromise".to_string(),
label: "题材承诺".to_string(),
value: "雨夜猫咪遗迹".to_string(),
status: "confirmed".to_string(),
},
visual_subject: spacetime_client::PuzzleAnchorItemRecord {
key: "visualSubject".to_string(),
label: "画面主体".to_string(),
value: String::new(),
status: "missing".to_string(),
},
visual_mood: spacetime_client::PuzzleAnchorItemRecord {
key: "visualMood".to_string(),
label: "视觉气质".to_string(),
value: String::new(),
status: "missing".to_string(),
},
composition_hooks: spacetime_client::PuzzleAnchorItemRecord {
key: "compositionHooks".to_string(),
label: "拼图记忆点".to_string(),
value: String::new(),
status: "missing".to_string(),
},
tags_and_forbidden: spacetime_client::PuzzleAnchorItemRecord {
key: "tagsAndForbidden".to_string(),
label: "标签与禁忌".to_string(),
value: String::new(),
status: "missing".to_string(),
},
},
draft: None,
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
suggested_actions: Vec::new(),
result_preview: None,
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
}
}
#[test]
fn extract_reply_text_from_partial_json_preserves_chinese_characters() {
let partial_json = r#"{"replyText":"夜雨猫咪遗迹","progressPercent":42"#;
@@ -498,13 +317,4 @@ mod tests {
"雨夜、猫咪、神庙遗迹;禁止文字水印"
);
}
#[test]
fn quick_fill_prompt_forbids_follow_up_questions() {
let prompt = build_puzzle_agent_prompt(&empty_session_record(), true);
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
}