691 lines
26 KiB
Rust
691 lines
26 KiB
Rust
#![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<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()));
|
||
}
|
||
}
|