This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -0,0 +1,690 @@
#![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()));
}
}