Files
Genarrative/server-rs/crates/api-server/src/prompt/visual_novel.rs
2026-05-08 11:44:42 +08:00

691 lines
26 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.
#![allow(dead_code)]
use platform_llm::{LlmMessage, LlmTextRequest};
use serde_json::{Value as JsonValue, json};
use shared_contracts::visual_novel::{VisualNovelResultDraft, VisualNovelRuntimeStep};
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
pub(crate) const VISUAL_NOVEL_CREATION_SYSTEM_PROMPT: &str = r#"你是百梦平台内的视觉小说模板创作导演。
你的任务是把用户的一句话、文档摘要或空白创建意图,生成一份可以进入结果页继续编辑的 VisualNovelResultDraft。
硬约束:
1. 只能输出一个 JSON 对象,不要输出 Markdown、代码块、解释或 UI 规则说明。
2. 输出内容必须是中文视觉小说底稿,补齐世界观、玩家身份、角色、场景、剧情阶段和开场。
3. 每个角色必须有可生成立绘的 appearance每个场景必须有可生成背景图的 description。
4. sourceMode 必须沿用输入的 idea、document 或 blank。
5. 图片、音乐、文档只能写平台资产引用或 null不能写大段 data URL。
6. 不要输出旧 TXT 播放记录、分享播放包、外部商业、运营、活动、展示横幅、交易或独立账号字段。
7. 不要发明第二套存档、发布、钱包、广场或资产系统。
8. publishReady 只有在 opening 场景、主要角色、剧情阶段和 2 到 4 个 initialChoices 都齐备时才可以为 true。
"#;
pub(crate) const VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT: &str = r#"你是百梦视觉小说运行时 GM。
你的任务是读取作品底稿、当前 run snapshot、玩家动作和最近历史然后输出下一轮 VisualNovelRuntimeStep[]。
硬约束:
1. 只能输出一个 JSON 数组不要输出对象包裹、Markdown、代码块、解释或 UI 规则说明。
2. 每轮 step 数量不能超过输入的 maxAssistantStepCountPerTurn。
3. 场景变化必须先输出 scene_change。
4. 旁白使用 narration角色说话使用 dialogue转场使用 transition。
5. 需要玩家选择时必须输出 choicechoice 内每项必须有 choiceId 和 text。
6. 关键剧情事实变化使用 flag数值倾向变化使用 metric。
7. 不要让前端从 raw_text 猜业务 step不要输出未定义 step 类型。
8. 不要输出旧 TXT 播放记录、分享播放包、屏幕记录、外部商业、运营、活动或独立保存元数据。
"#;
pub(crate) const VISUAL_NOVEL_REPAIR_SYSTEM_PROMPT: &str = r#"你是视觉小说结构化输出修复器。
你的任务是把上一次模型输出修复为目标 JSON 契约。
硬约束:
1. 只能输出目标 JSON不要解释错误原因。
2. 不能新增目标契约之外的字段。
3. 不要把普通历史、运行事件或 raw_text 改写成旧 TXT 播放包、屏幕记录或分享片段。
4. 如果原文缺失必要信息,只补最小可运行占位值,并保持中文内容。
"#;
const VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT: &str = r#"{
"profileId": null,
"workTitle": "",
"workDescription": "",
"workTags": [],
"coverImageSrc": null,
"sourceMode": "idea",
"sourceAssetIds": [],
"world": {
"title": "",
"summary": "",
"background": "",
"premise": "",
"literaryStyle": "",
"playerRole": "",
"defaultTone": ""
},
"characters": [
{
"characterId": "char-main-1",
"name": "",
"gender": null,
"role": "main",
"appearance": "",
"personality": "",
"tone": "",
"background": "",
"relationshipToPlayer": "",
"imageAssets": [],
"defaultExpression": null,
"isPlayerVisible": false
}
],
"scenes": [
{
"sceneId": "scene-opening",
"name": "",
"description": "",
"backgroundImageSrc": null,
"musicSrc": null,
"ambientSoundSrc": null,
"availability": "opening",
"phaseIds": []
}
],
"storyPhases": [
{
"phaseId": "phase-opening",
"title": "",
"goal": "",
"summary": "",
"entryCondition": "",
"exitCondition": "",
"sceneIds": ["scene-opening"],
"characterIds": ["char-main-1"],
"suggestedChoices": []
}
],
"opening": {
"sceneId": "scene-opening",
"narration": "",
"speakerCharacterId": null,
"firstDialogue": null,
"initialChoices": [
{ "choiceId": "choice-opening-1", "text": "", "actionHint": null },
{ "choiceId": "choice-opening-2", "text": "", "actionHint": null }
]
},
"runtimeConfig": {
"textModeEnabled": true,
"defaultTextMode": false,
"maxHistoryEntries": 80,
"maxAssistantStepCountPerTurn": 8,
"allowFreeTextAction": true,
"allowHistoryRegeneration": true,
"attributePanelMode": "off",
"saveArchiveEnabled": true
},
"publishReady": false,
"validationIssues": [],
"updatedAt": "ISO-8601"
}"#;
const VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT: &str = r#"[
{ "type": "scene_change", "sceneId": "scene-opening", "backgroundImageSrc": null, "musicSrc": null },
{ "type": "narration", "text": "" },
{ "type": "dialogue", "characterId": "char-main-1", "characterName": "", "expression": null, "text": "" },
{ "type": "transition", "transitionKind": "fade", "text": null },
{ "type": "flag", "key": "", "value": true },
{ "type": "metric", "key": "", "delta": 1 },
{ "type": "choice", "choices": [{ "choiceId": "choice-next-1", "text": "", "actionHint": null }] }
]"#;
#[derive(Clone, Debug)]
pub(crate) struct VisualNovelCreationPromptParams<'a> {
pub(crate) source_mode: &'a str,
pub(crate) seed_text: Option<&'a str>,
pub(crate) source_asset_ids: &'a [String],
pub(crate) document_summary: Option<&'a str>,
pub(crate) current_draft: Option<&'a JsonValue>,
pub(crate) recent_messages: &'a [JsonValue],
pub(crate) now_iso: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct VisualNovelRuntimePromptParams<'a> {
pub(crate) work_profile: &'a JsonValue,
pub(crate) run_snapshot: &'a JsonValue,
pub(crate) runtime_action: &'a JsonValue,
pub(crate) recent_history: &'a [JsonValue],
pub(crate) max_assistant_step_count_per_turn: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum VisualNovelRepairTarget {
ResultDraft,
RuntimeSteps,
}
impl VisualNovelRepairTarget {
fn label(self) -> &'static str {
match self {
Self::ResultDraft => "VisualNovelResultDraft",
Self::RuntimeSteps => "VisualNovelRuntimeStep[]",
}
}
fn contract(self) -> &'static str {
match self {
Self::ResultDraft => VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT,
Self::RuntimeSteps => VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct VisualNovelRepairPromptParams<'a> {
pub(crate) target: VisualNovelRepairTarget,
pub(crate) raw_text: &'a str,
pub(crate) parse_error: &'a str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct VisualNovelPromptParseFailure {
pub(crate) target: VisualNovelRepairTarget,
pub(crate) message: String,
}
impl VisualNovelPromptParseFailure {
pub(crate) fn retryable_message(&self) -> String {
format!(
"{} 输出结构不可解析,可重试或进入 repair{}",
self.target.label(),
self.message
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct VisualNovelToolDescriptor {
pub(crate) name: &'static str,
pub(crate) description: &'static str,
pub(crate) input_schema: JsonValue,
}
pub(crate) fn build_visual_novel_creation_user_prompt(
params: VisualNovelCreationPromptParams<'_>,
) -> String {
json!({
"task": "generate_visual_novel_result_draft",
"sourceMode": params.source_mode,
"seedText": params.seed_text.unwrap_or("").trim(),
"sourceAssetIds": params.source_asset_ids,
"documentSummary": params.document_summary.unwrap_or("").trim(),
"currentDraft": params.current_draft,
"recentMessages": params.recent_messages,
"nowIso": params.now_iso,
"draftRequirements": {
"mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色",
"scenes": "3 到 8 个,至少 1 个 opening 场景",
"storyPhases": "3 到 6 个,第一阶段可从 opening 进入",
"initialChoices": "2 到 4 个",
"runtimeConfigDefaults": "沿用契约默认值attributePanelMode 默认为 off"
},
"outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT
})
.to_string()
}
pub(crate) fn build_visual_novel_runtime_user_prompt(
params: VisualNovelRuntimePromptParams<'_>,
) -> String {
json!({
"task": "generate_visual_novel_runtime_steps",
"workProfile": params.work_profile,
"runSnapshot": params.run_snapshot,
"runtimeAction": params.runtime_action,
"recentHistory": params.recent_history,
"maxAssistantStepCountPerTurn": params.max_assistant_step_count_per_turn,
"runtimeRules": [
"只以 step 数组作为正式业务输出",
"当前选择项必须来自 runSnapshot.availableChoices 或由本轮 choice step 重新给出",
"如果玩家自由输入改变事实,必须用 flag 或 metric 表达可持久化变化",
"不要在输出中夹带 raw_text、debug、prompt、historyPlayback 或平台运营字段"
],
"outputContract": VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT
})
.to_string()
}
pub(crate) fn build_visual_novel_repair_user_prompt(
params: VisualNovelRepairPromptParams<'_>,
) -> String {
json!({
"task": "repair_visual_novel_structured_output",
"target": params.target.label(),
"parseError": params.parse_error,
"rawText": params.raw_text,
"outputContract": params.target.contract()
})
.to_string()
}
pub(crate) fn build_visual_novel_creation_llm_request(
params: VisualNovelCreationPromptParams<'_>,
enable_web_search: bool,
) -> LlmTextRequest {
LlmTextRequest::new(vec![
LlmMessage::system(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT),
LlmMessage::user(build_visual_novel_creation_user_prompt(params)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(enable_web_search)
}
pub(crate) fn build_visual_novel_runtime_llm_request(
params: VisualNovelRuntimePromptParams<'_>,
) -> LlmTextRequest {
LlmTextRequest::new(vec![
LlmMessage::system(VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT),
LlmMessage::user(build_visual_novel_runtime_user_prompt(params)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
}
pub(crate) fn build_visual_novel_repair_llm_request(
params: VisualNovelRepairPromptParams<'_>,
) -> LlmTextRequest {
LlmTextRequest::new(vec![
LlmMessage::system(VISUAL_NOVEL_REPAIR_SYSTEM_PROMPT),
LlmMessage::user(build_visual_novel_repair_user_prompt(params)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
}
pub(crate) fn visual_novel_tool_descriptors() -> Vec<VisualNovelToolDescriptor> {
vec![
VisualNovelToolDescriptor {
name: "visual_novel_apply_creation_action",
description: "执行视觉小说创作 action写回 VisualNovelResultDraft 或编译平台 work profile 草稿。",
input_schema: json!({
"type": "object",
"required": ["kind"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": [
"generate_draft",
"patch_world",
"patch_character",
"patch_scene",
"patch_story_phase",
"compile_work_profile"
]
},
"targetId": { "type": ["string", "null"] },
"payload": { "type": "object", "additionalProperties": true }
}
}),
},
VisualNovelToolDescriptor {
name: "visual_novel_generate_image_asset",
description: "为视觉小说角色立绘或场景背景生成图片,并返回平台资产引用。",
input_schema: json!({
"type": "object",
"required": ["kind", "targetId", "prompt"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": ["generate_scene_image", "generate_character_image"]
},
"targetId": { "type": "string", "minLength": 1 },
"prompt": { "type": "string", "minLength": 1 },
"styleHints": { "type": "array", "items": { "type": "string" } },
"sourceImageAssetId": { "type": ["string", "null"] }
}
}),
},
]
}
pub(crate) fn parse_visual_novel_result_draft_fixture(
text: &str,
) -> Result<VisualNovelResultDraft, VisualNovelPromptParseFailure> {
let value = extract_json_root(
text,
JsonRootShape::Object,
VisualNovelRepairTarget::ResultDraft,
)?;
serde_json::from_value(value).map_err(|error| VisualNovelPromptParseFailure {
target: VisualNovelRepairTarget::ResultDraft,
message: error.to_string(),
})
}
pub(crate) fn parse_visual_novel_runtime_steps_fixture(
text: &str,
) -> Result<Vec<VisualNovelRuntimeStep>, VisualNovelPromptParseFailure> {
let value = extract_json_root(
text,
JsonRootShape::Array,
VisualNovelRepairTarget::RuntimeSteps,
)?;
serde_json::from_value(value).map_err(|error| VisualNovelPromptParseFailure {
target: VisualNovelRepairTarget::RuntimeSteps,
message: error.to_string(),
})
}
#[derive(Clone, Copy)]
enum JsonRootShape {
Object,
Array,
}
fn extract_json_root(
text: &str,
shape: JsonRootShape,
target: VisualNovelRepairTarget,
) -> Result<JsonValue, VisualNovelPromptParseFailure> {
let trimmed = strip_json_code_fence(text.trim());
if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) {
return Ok(value);
}
let (start_char, end_char) = match shape {
JsonRootShape::Object => ('{', '}'),
JsonRootShape::Array => ('[', ']'),
};
let start = trimmed.find(start_char);
let end = trimmed.rfind(end_char);
match (start, end) {
(Some(start), Some(end)) if end > start => {
serde_json::from_str::<JsonValue>(&trimmed[start..=end]).map_err(|error| {
VisualNovelPromptParseFailure {
target,
message: error.to_string(),
}
})
}
_ => Err(VisualNovelPromptParseFailure {
target,
message: format!("未找到目标 JSON {}", target.label()),
}),
}
}
fn strip_json_code_fence(text: &str) -> &str {
let trimmed = text.trim();
if !trimmed.starts_with("```") {
return trimmed;
}
let without_start = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```JSON"))
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed)
.trim();
without_start
.strip_suffix("```")
.unwrap_or(without_start)
.trim()
}
#[cfg(test)]
mod tests {
use platform_llm::LlmTextProtocol;
use serde_json::json;
use super::*;
fn source_asset_ids() -> Vec<String> {
vec!["asset-doc-1".to_string()]
}
fn creation_params<'a>(source_asset_ids: &'a [String]) -> VisualNovelCreationPromptParams<'a> {
VisualNovelCreationPromptParams {
source_mode: "idea",
seed_text: Some("雨夜里,只在午夜出现的书店会归还人们遗失的名字。"),
source_asset_ids,
document_summary: None,
current_draft: None,
recent_messages: &[],
now_iso: "2026-05-05T12:00:00Z",
}
}
fn runtime_params<'a>(
work_profile: &'a JsonValue,
run_snapshot: &'a JsonValue,
runtime_action: &'a JsonValue,
) -> VisualNovelRuntimePromptParams<'a> {
VisualNovelRuntimePromptParams {
work_profile,
run_snapshot,
runtime_action,
recent_history: &[],
max_assistant_step_count_per_turn: 8,
}
}
fn sample_draft() -> JsonValue {
json!({
"profileId": null,
"workTitle": "雨夜书店",
"workDescription": "一名失去名字的读者在午夜书店寻找真相。",
"workTags": ["悬疑", "治愈"],
"coverImageSrc": null,
"sourceMode": "idea",
"sourceAssetIds": [],
"world": {
"title": "雨夜书店",
"summary": "午夜书店会收留遗失名字的人。",
"background": "旧城区尽头有一家只在雨夜开门的书店,书架保存着人们遗忘的片段。",
"premise": "玩家要在天亮前找回自己的名字。",
"literaryStyle": "细腻、克制、轻悬疑",
"playerRole": "失去名字的读者",
"defaultTone": "雨夜、温柔、隐秘"
},
"characters": [
{
"characterId": "char-keeper",
"name": "林栖",
"gender": "",
"role": "main",
"appearance": "银灰短发,深绿围裙,手中常拿一盏铜灯,适合半身立绘。",
"personality": "温和但不轻易透露真相",
"tone": "低声、像在翻旧书",
"background": "午夜书店的看守者。",
"relationshipToPlayer": "知道玩家名字的一部分。",
"imageAssets": [],
"defaultExpression": "calm",
"isPlayerVisible": false
}
],
"scenes": [
{
"sceneId": "scene-bookstore",
"name": "午夜书店",
"description": "窄巷尽头的木门半开,暖黄灯光落在潮湿石板上,室内书架高而幽深。",
"backgroundImageSrc": null,
"musicSrc": null,
"ambientSoundSrc": null,
"availability": "opening",
"phaseIds": ["phase-opening"]
}
],
"storyPhases": [
{
"phaseId": "phase-opening",
"title": "失名之夜",
"goal": "确认玩家为何失去名字",
"summary": "玩家进入书店,与林栖第一次交谈。",
"entryCondition": "opening",
"exitCondition": "找到第一张名字书签",
"sceneIds": ["scene-bookstore"],
"characterIds": ["char-keeper"],
"suggestedChoices": ["询问书店来历", "查看柜台上的旧书"]
}
],
"opening": {
"sceneId": "scene-bookstore",
"narration": "雨水顺着伞尖落下时,你发现门牌上的字正在一点点亮起。",
"speakerCharacterId": "char-keeper",
"firstDialogue": "你终于来了。名字丢失的人,总会先听见这场雨。",
"initialChoices": [
{ "choiceId": "choice-ask-name", "text": "询问自己的名字在哪里", "actionHint": "向林栖确认线索" },
{ "choiceId": "choice-look-book", "text": "查看柜台上的旧书", "actionHint": "寻找名字书签" }
]
},
"runtimeConfig": {
"textModeEnabled": true,
"defaultTextMode": false,
"maxHistoryEntries": 80,
"maxAssistantStepCountPerTurn": 8,
"allowFreeTextAction": true,
"allowHistoryRegeneration": true,
"attributePanelMode": "off",
"saveArchiveEnabled": true
},
"publishReady": true,
"validationIssues": [],
"updatedAt": "2026-05-05T12:00:00Z"
})
}
#[test]
fn creation_fixture_parses_as_visual_novel_result_draft() {
let raw_text = format!("模型输出如下:\n{}", sample_draft());
let draft = parse_visual_novel_result_draft_fixture(raw_text.as_str())
.expect("draft fixture should parse");
assert_eq!(draft.work_title, "雨夜书店");
assert_eq!(draft.characters[0].character_id, "char-keeper");
assert_eq!(draft.opening.initial_choices.len(), 2);
}
#[test]
fn runtime_fixture_parses_as_typed_steps() {
let raw_text = json!([
{ "type": "scene_change", "sceneId": "scene-bookstore", "backgroundImageSrc": null, "musicSrc": null },
{ "type": "narration", "text": "门铃轻响,雨声像被书页吸走。" },
{ "type": "dialogue", "characterId": "char-keeper", "characterName": "林栖", "expression": "calm", "text": "先别急着找答案,先告诉我你还记得什么。" },
{ "type": "flag", "key": "met_keeper", "value": true },
{ "type": "metric", "key": "keeper_trust", "delta": 1 },
{
"type": "choice",
"choices": [
{ "choiceId": "choice-tell-memory", "text": "说出最后记得的街名", "actionHint": "提供线索" },
{ "choiceId": "choice-stay-silent", "text": "保持沉默观察她", "actionHint": "观察林栖反应" }
]
}
])
.to_string();
let steps = parse_visual_novel_runtime_steps_fixture(raw_text.as_str())
.expect("runtime fixture should parse");
assert_eq!(steps.len(), 6);
assert!(matches!(
steps[0],
VisualNovelRuntimeStep::SceneChange { .. }
));
assert!(matches!(steps[5], VisualNovelRuntimeStep::Choice { .. }));
}
#[test]
fn bad_runtime_output_can_enter_repair_prompt() {
let failure = parse_visual_novel_runtime_steps_fixture("林栖说:欢迎来到书店。")
.expect_err("bad output should fail");
let retryable_message = failure.retryable_message();
let repair_prompt = build_visual_novel_repair_user_prompt(VisualNovelRepairPromptParams {
target: failure.target,
raw_text: "林栖说:欢迎来到书店。",
parse_error: failure.message.as_str(),
});
assert!(retryable_message.contains("可重试"));
assert!(repair_prompt.contains("VisualNovelRuntimeStep[]"));
assert!(repair_prompt.contains("林栖说"));
assert!(repair_prompt.contains("scene_change"));
}
#[test]
fn llm_requests_use_responses_template_model() {
let asset_ids = source_asset_ids();
let creation_request =
build_visual_novel_creation_llm_request(creation_params(asset_ids.as_slice()), true);
assert_eq!(
creation_request.model.as_deref(),
Some(CREATION_TEMPLATE_LLM_MODEL)
);
assert_eq!(creation_request.protocol, LlmTextProtocol::Responses);
assert!(creation_request.enable_web_search);
assert!(
creation_request.messages[0]
.content
.contains("VisualNovelResultDraft")
);
assert!(
creation_request.messages[1]
.content
.contains("sourceAssetIds")
);
let work_profile = sample_draft();
let run_snapshot = json!({ "runId": "run-1", "availableChoices": [] });
let runtime_action = json!({ "actionKind": "continue", "clientEventId": "event-1" });
let runtime_request = build_visual_novel_runtime_llm_request(runtime_params(
&work_profile,
&run_snapshot,
&runtime_action,
));
assert_eq!(
runtime_request.model.as_deref(),
Some(CREATION_TEMPLATE_LLM_MODEL)
);
assert_eq!(runtime_request.protocol, LlmTextProtocol::Responses);
assert!(!runtime_request.enable_web_search);
assert!(
runtime_request.messages[0]
.content
.contains("VisualNovelRuntimeStep[]")
);
}
#[test]
fn prompts_and_tools_guard_against_external_platform_fields() {
assert!(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT.contains("外部商业"));
assert!(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT.contains("独立账号"));
assert!(VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT.contains("独立保存"));
let tools = visual_novel_tool_descriptors();
let tool_payload = serde_json::to_string(&json!(
tools
.iter()
.map(|tool| json!({
"name": tool.name,
"description": tool.description,
"inputSchema": tool.input_schema,
}))
.collect::<Vec<_>>()
))
.expect("tools should serialize");
assert!(tool_payload.contains("generate_scene_image"));
assert!(tool_payload.contains("generate_character_image"));
assert!(tool_payload.contains("compile_work_profile"));
let legacy_playback_marker = format!("{}{}", "re", "play");
assert!(!tool_payload.contains(&legacy_playback_marker));
assert!(!tool_payload.contains(&legacy_playback_marker.to_uppercase()));
}
}