213 lines
8.0 KiB
Rust
213 lines
8.0 KiB
Rust
use module_puzzle::{PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack};
|
||
use serde_json::{Value as JsonValue, json};
|
||
use spacetime_client::{
|
||
PuzzleAgentMessageRecord, PuzzleAgentSessionRecord, PuzzleAnchorPackRecord,
|
||
};
|
||
|
||
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;
|
||
|
||
/// 拼图共创 Agent 的系统提示词。
|
||
///
|
||
/// 这里作为拼图聊天提示词主源,业务文件只负责调用 LLM、解析结果和写回状态。
|
||
pub(crate) 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
|
||
"#;
|
||
|
||
/// 拼图共创 Agent 单轮 JSON 输出契约。
|
||
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"
|
||
}
|
||
}
|
||
}"#;
|
||
|
||
/// 拼图共创 Agent 的用户提示词,用于触发模型按系统约定返回单轮 JSON。
|
||
pub(crate) const PUZZLE_AGENT_JSON_TURN_USER_PROMPT: &str = "请按约定输出这一轮的 JSON。";
|
||
|
||
/// 拼图草稿生成对话提示词脚本。
|
||
pub(crate) 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 = serialize_puzzle_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 = PUZZLE_AGENT_OUTPUT_CONTRACT,
|
||
)
|
||
}
|
||
|
||
/// 将 SpacetimeDB 记录态锚点序列化成提示词可读 JSON。
|
||
pub(crate) fn serialize_puzzle_record_anchor_pack(record: &PuzzleAnchorPackRecord) -> String {
|
||
serde_json::to_string_pretty(&map_puzzle_record_anchor_pack(record)).unwrap_or_else(|_| {
|
||
serde_json::to_string_pretty(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())
|
||
})
|
||
}
|
||
|
||
fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec<JsonValue> {
|
||
messages
|
||
.iter()
|
||
.map(|message| {
|
||
json!({
|
||
"role": message.role,
|
||
"kind": message.kind,
|
||
"content": message.text,
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn map_puzzle_record_anchor_pack(record: &PuzzleAnchorPackRecord) -> PuzzleAnchorPack {
|
||
PuzzleAnchorPack {
|
||
theme_promise: map_puzzle_record_anchor_item(&record.theme_promise),
|
||
visual_subject: map_puzzle_record_anchor_item(&record.visual_subject),
|
||
visual_mood: map_puzzle_record_anchor_item(&record.visual_mood),
|
||
composition_hooks: map_puzzle_record_anchor_item(&record.composition_hooks),
|
||
tags_and_forbidden: map_puzzle_record_anchor_item(&record.tags_and_forbidden),
|
||
}
|
||
}
|
||
|
||
fn map_puzzle_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_puzzle_anchor_status(record.status.as_str()),
|
||
}
|
||
}
|
||
|
||
fn parse_puzzle_anchor_status(value: &str) -> PuzzleAnchorStatus {
|
||
match value {
|
||
"confirmed" => PuzzleAnchorStatus::Confirmed,
|
||
"locked" => PuzzleAnchorStatus::Locked,
|
||
"inferred" => PuzzleAnchorStatus::Inferred,
|
||
_ => PuzzleAnchorStatus::Missing,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::build_puzzle_agent_prompt;
|
||
|
||
fn anchor_item(
|
||
key: &str,
|
||
label: &str,
|
||
value: &str,
|
||
status: &str,
|
||
) -> spacetime_client::PuzzleAnchorItemRecord {
|
||
spacetime_client::PuzzleAnchorItemRecord {
|
||
key: key.to_string(),
|
||
label: label.to_string(),
|
||
value: value.to_string(),
|
||
status: status.to_string(),
|
||
}
|
||
}
|
||
|
||
fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord {
|
||
spacetime_client::PuzzleAgentSessionRecord {
|
||
session_id: "puzzle-session-test".to_string(),
|
||
seed_text: "雨夜猫咪遗迹".to_string(),
|
||
current_turn: 2,
|
||
progress_percent: 60,
|
||
stage: "collecting_anchors".to_string(),
|
||
anchor_pack: spacetime_client::PuzzleAnchorPackRecord {
|
||
theme_promise: anchor_item("themePromise", "题材承诺", "雨夜猫咪遗迹", "confirmed"),
|
||
visual_subject: anchor_item("visualSubject", "画面主体", "", "missing"),
|
||
visual_mood: anchor_item("visualMood", "视觉气质", "", "missing"),
|
||
composition_hooks: anchor_item("compositionHooks", "拼图记忆点", "", "missing"),
|
||
tags_and_forbidden: anchor_item("tagsAndForbidden", "标签与禁忌", "", "missing"),
|
||
},
|
||
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 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"));
|
||
}
|
||
}
|