#![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. 需要玩家选择时必须输出 choice,choice 内每项必须有 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 { 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 { 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, 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 { let trimmed = strip_json_code_fence(text.trim()); if let Ok(value) = serde_json::from_str::(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::(&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 { 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::>() )) .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())); } }