feat(engine): merge cloudflare-migration — paradigm D engine, BYOK proxy, story persistence (#95)

Squash-merge the cloudflare-migration branch (7 commits by Kai ki) into
staging with conflict resolution, feature integration, and bug fixes.

Engine:
- Paradigm D: single-stream Writer replacing dual-phase Plan/Beats
- Delete Architect agent; story bible generated via Writer <plan> tag
- Modular prompt architecture (segments/registry/builder)
- StreamRouter for tagged stream splitting (<plan>/<story>/<choices>)

Infrastructure:
- Cloudflare Workers deployment (wrangler.jsonc, OpenNext adapter)
- D1 database schema + Drizzle ORM (scaffolded, not yet active)
- R2 storage helpers (scaffolded, not yet active)
- Story persistence API routes + client-side persistence

BYOK (Bring Your Own Key):
- /api/llm/user-proxy with SSRF-protected LLM proxy (+ requireUser auth)
- CORS-aware fetch in ai-client: auto-detect CORS failure, fallback to
  server proxy transparently via OpenAI SDK custom fetch
- BYO config support added to classify-freeform and vision routes
- SettingsModal CORS privacy notice (keys never logged/stored)

SSE streaming:
- engineClient.ts: fetchSSE helper for progressive scene events
- startSession/requestScene accept optional emit callback
- Fix SSE error event field name (error → message) in scene/start routes

i18n integration:
- Wire buildLanguageDirective into paradigm D's prompt builder
- Update corsNotice i18n keys (zh-CN/en/ja) with CORS proxy privacy text
- Preserve Session.language + LanguageSwitcher from i18n commit

Co-authored-by: Kai ki <155355644+zbf1009@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zonghao Yuan
2026-06-18 18:05:38 +08:00
committed by GitHub
parent 05bd7e229c
commit 0e4c2ebef4
78 changed files with 7396 additions and 919 deletions
+508
View File
@@ -0,0 +1,508 @@
#!/usr/bin/env node
/**
* Phase 5 验证测试脚本
*
* 用途:
* - Task 18: 禁词表验证(生成10场景,统计禁词)
* - Task 20: CharacterPersona 注入验证
* - Task 21: 世界书触发验证
* - Task 22: Prompt Cache 命中率监控
* - Task 23: Token 预算验证
*
* 使用方法:
* node scripts/test-phase5.mjs --task=18 --url=https://infiplot.y-9e6.workers.dev
*/
import { promises as fs } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 禁词表(来自 lib/engine/prompts/segments/writer/style-base.ts
const FORBIDDEN_WORDS = [
"一丝", "不易察觉", "鲜明对比", "喉结", "纽扣", "弧度",
"不禁", "悄然", "涟漪", "交织"
];
// 命令行参数解析
const args = process.argv.slice(2).reduce((acc, arg) => {
const [key, value] = arg.split("=");
acc[key.replace("--", "")] = value || true;
return acc;
}, {});
const BASE_URL = args.url || "https://infiplot.y-9e6.workers.dev";
const TASK = args.task || "18";
console.log(`🔍 Phase 5 验证测试 - Task ${TASK}`);
console.log(`📍 目标环境: ${BASE_URL}\n`);
// ──────────────────────────────────────────────────────────────────────
// Task 18: 禁词表验证
// ──────────────────────────────────────────────────────────────────────
async function task18_forbiddenWords() {
console.log("📋 Task 18: 禁词表验证(生成10场景统计禁词)\n");
const scenarios = [
{ type: "开局", seed: "一个平凡的清晨,主角醒来发现窗外有奇怪的光" },
{ type: "对话", seed: "两个角色在咖啡厅里讨论一个秘密" },
{ type: "动作", seed: "主角在图书馆里发现了一本禁书" },
{ type: "情感", seed: "两个朋友因为误会产生了隔阂" },
{ type: "悬疑", seed: "主角收到了一封没有署名的信" },
{ type: "冲突", seed: "主角和反派在天台对峙" },
{ type: "浪漫", seed: "两个人在雨中相遇" },
{ type: "惊悚", seed: "主角发现镜子里的倒影不是自己" },
{ type: "日常", seed: "主角在学校食堂排队买午饭" },
{ type: "转折", seed: "主角发现自己信任的人背叛了自己" }
];
const results = [];
let totalForbiddenCount = 0;
let totalCharCount = 0;
for (let i = 0; i < scenarios.length; i++) {
const scenario = scenarios[i];
console.log(`\n🎬 [${i + 1}/10] 场景类型: ${scenario.type}`);
console.log(` 开场种子: ${scenario.seed}`);
try {
// 调用 /api/start
const startRes = await fetch(`${BASE_URL}/api/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
worldSetting: "现代都市,有超自然元素",
styleGuide: "写实风格,带一点魔幻色彩",
openingPrompt: scenario.seed,
orientation: "landscape"
})
});
if (!startRes.ok) {
console.error(` ❌ API 错误: ${startRes.status}`);
continue;
}
const data = await startRes.json();
// StartResponse: { sessionId, scene, imageUrl, characters, storyState }
const scene = data.scene;
if (!scene || !scene.beats) {
console.error(` ❌ 场景数据缺失`, JSON.stringify(Object.keys(data)));
continue;
}
// 提取所有文本
const texts = scene.beats
.map(b => [b.narration, b.line].filter(Boolean).join(" "))
.join(" ");
totalCharCount += texts.length;
// 统计禁词
const forbiddenFound = {};
let sceneForbiddenCount = 0;
for (const word of FORBIDDEN_WORDS) {
const count = (texts.match(new RegExp(word, "g")) || []).length;
if (count > 0) {
forbiddenFound[word] = count;
sceneForbiddenCount += count;
}
}
totalForbiddenCount += sceneForbiddenCount;
console.log(` ✅ 生成成功 (${texts.length} 字)`);
if (sceneForbiddenCount > 0) {
console.log(` ⚠️ 禁词出现: ${sceneForbiddenCount}`);
for (const [word, count] of Object.entries(forbiddenFound)) {
console.log(` - "${word}": ${count}`);
}
} else {
console.log(` ✨ 无禁词`);
}
results.push({
type: scenario.type,
seed: scenario.seed,
textLength: texts.length,
forbiddenCount: sceneForbiddenCount,
forbiddenWords: forbiddenFound,
sceneKey: scene.sceneKey,
beatCount: scene.beats.length
});
} catch (err) {
console.error(` ❌ 请求失败: ${err.message}`);
}
// 避免 rate limit
await new Promise(resolve => setTimeout(resolve, 2000));
}
// 输出统计结果
console.log("\n\n" + "═".repeat(60));
console.log("📊 Task 18 统计结果");
console.log("═".repeat(60));
console.log(`生成场景: ${results.length} / 10`);
console.log(`总字数: ${totalCharCount.toLocaleString()}`);
console.log(`禁词总数: ${totalForbiddenCount}`);
console.log(`禁词密度: ${(totalForbiddenCount / totalCharCount * 10000).toFixed(2)} 次/万字`);
console.log(`\n期望目标: 禁词出现率下降 >80% (需要对比旧版本基线)`);
// 保存详细报告
const reportPath = join(__dirname, "../.spec-workflow/specs/prompt-architecture-redesign/task18-report.json");
await fs.writeFile(reportPath, JSON.stringify({
timestamp: new Date().toISOString(),
summary: {
scenesGenerated: results.length,
totalChars: totalCharCount,
totalForbiddenWords: totalForbiddenCount,
forbiddenDensity: totalForbiddenCount / totalCharCount * 10000
},
details: results
}, null, 2));
console.log(`\n📄 详细报告已保存: ${reportPath}`);
}
// ──────────────────────────────────────────────────────────────────────
// Task 20: CharacterPersona 注入验证
// ──────────────────────────────────────────────────────────────────────
async function task20_personaInjection() {
console.log("📋 Task 20: CharacterPersona 注入验证\n");
const testCases = [
{
name: "傲娇女生测试",
worldSetting: "现代校园",
styleGuide: "轻松日常风格",
openingPrompt: "主角在学校走廊遇到了同班的凛,她似乎有话要说",
expectedPersona: {
name: "凛",
persona: "傲娇女生,外冷内热,喜欢主角但嘴硬",
speakingStyle: "口头禅'哼',短句,语气强硬但偶尔露出温柔",
sampleDialogue: ["哼,才不是担心你呢!", "你…你别误会啊!"]
}
},
{
name: "沉默寡言少年测试",
worldSetting: "现代校园",
styleGuide: "安静温柔",
openingPrompt: "主角在图书馆遇到了总是独自看书的少年樱",
expectedPersona: {
name: "樱",
persona: "沉默寡言的少年,内心细腻,不善表达",
speakingStyle: "惜字如金,多用省略号和短句,语气平静",
sampleDialogue: ["嗯…", "……没什么。", "谢谢。"]
}
}
];
const results = [];
for (const testCase of testCases) {
console.log(`\n🎭 ${testCase.name}`);
console.log(` 角色: ${testCase.expectedPersona.name}`);
console.log(` Persona: ${testCase.expectedPersona.persona}`);
console.log(` 说话风格: ${testCase.expectedPersona.speakingStyle}`);
try {
// 第一次调用 /api/start,然后手动注入 persona(模拟后续场景)
const startRes = await fetch(`${BASE_URL}/api/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
worldSetting: testCase.worldSetting,
styleGuide: testCase.styleGuide,
openingPrompt: testCase.openingPrompt,
orientation: "landscape"
})
});
if (!startRes.ok) {
console.error(` ❌ API 错误: ${startRes.status}`);
continue;
}
const data = await startRes.json();
// Reconstruct a Session object from StartResponse
const session = {
id: data.sessionId,
createdAt: Date.now(),
worldSetting: testCase.worldSetting,
styleGuide: testCase.styleGuide,
history: [{
scene: data.scene,
visitedBeatIds: [data.scene.entryBeatId || data.scene.beats[0].id],
exit: null
}],
characters: data.characters,
storyState: data.storyState,
orientation: "landscape"
};
// 手动注入角色 persona(模拟已设计的角色)
const targetChar = session.characters.find(c => c.name === testCase.expectedPersona.name);
if (targetChar) {
Object.assign(targetChar, testCase.expectedPersona);
} else {
session.characters.push(testCase.expectedPersona);
}
// 调用 /api/scene 生成下一场景
const sceneRes = await fetch(`${BASE_URL}/api/scene`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session })
});
if (!sceneRes.ok) {
console.error(` ❌ Scene API 错误: ${sceneRes.status}`);
continue;
}
const sceneData = await sceneRes.json();
// SceneResponse: { scene, imageUrl, characters, storyState }
const scene = sceneData.scene;
// 提取该角色的对白
const characterLines = scene.beats
.filter(b => b.speaker === testCase.expectedPersona.name && b.line)
.map(b => ({
line: b.line,
delivery: b.lineDelivery
}));
console.log(` ✅ 生成成功,${testCase.expectedPersona.name}${characterLines.length} 句对白`);
if (characterLines.length > 0) {
console.log(` 💬 对白示例:`);
characterLines.slice(0, 3).forEach(l => {
console.log(` "${l.line}"${l.delivery ? ` [${l.delivery}]` : ""}`);
});
} else {
console.log(` ⚠️ 该角色未说话(可能未出场)`);
}
results.push({
testCase: testCase.name,
character: testCase.expectedPersona.name,
linesGenerated: characterLines.length,
lines: characterLines,
passed: characterLines.length > 0
});
} catch (err) {
console.error(` ❌ 请求失败: ${err.message}`);
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
// 输出统计
console.log("\n\n" + "═".repeat(60));
console.log("📊 Task 20 统计结果");
console.log("═".repeat(60));
console.log(`测试用例: ${results.length} / ${testCases.length}`);
console.log(`通过用例: ${results.filter(r => r.passed).length}`);
console.log(`\n💡 需要人工检查对白是否体现 persona 特征`);
const reportPath = join(__dirname, "../.spec-workflow/specs/prompt-architecture-redesign/task20-report.json");
await fs.writeFile(reportPath, JSON.stringify({
timestamp: new Date().toISOString(),
results
}, null, 2));
console.log(`\n📄 详细报告已保存: ${reportPath}`);
}
// ──────────────────────────────────────────────────────────────────────
// Task 21: 世界书触发验证
// ──────────────────────────────────────────────────────────────────────
async function task21_worldBookTrigger() {
console.log("📋 Task 21: 世界书触发验证\n");
const worldBooks = [{
id: "test-wb",
name: "测试世界书",
entries: [
{
id: "const-1",
keys: [],
content: "这所学校位于县城西郊,建校已有50年历史",
position: "constant",
priority: 10
},
{
id: "trig-1",
keys: ["教室", "上课"],
content: "3年2班教室位于教学楼3层,共有42个座位,窗户朝南",
position: "triggered",
priority: 5
},
{
id: "trig-2",
keys: ["食堂", "午饭"],
content: "学校食堂在一楼,有A、B两个窗口,A窗口供应盖饭,B窗口供应面食",
position: "triggered",
priority: 5
}
]
}];
const scenarios = [
{ seed: "主角走进3年2班教室,准备上课", expectedTrigger: ["trig-1"], keywords: ["教室", "上课"] },
{ seed: "放学后,主角去学校食堂吃午饭", expectedTrigger: ["trig-2"], keywords: ["食堂", "午饭"] },
{ seed: "主角在操场上遇到了朋友", expectedTrigger: [], keywords: [] },
{ seed: "主角在图书馆看书", expectedTrigger: [], keywords: [] },
{ seed: "主角在教室里和同学讨论作业", expectedTrigger: ["trig-1"], keywords: ["教室"] }
];
const results = [];
for (let i = 0; i < scenarios.length; i++) {
const scenario = scenarios[i];
console.log(`\n🎬 [${i + 1}/${scenarios.length}] ${scenario.seed}`);
console.log(` 期望触发: ${scenario.expectedTrigger.length > 0 ? scenario.expectedTrigger.join(", ") : "无"}`);
try {
// Step 1: /api/start to get a session (worldBooks injected afterward)
const startRes = await fetch(`${BASE_URL}/api/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
worldSetting: `现代校园。${scenario.seed}`,
styleGuide: "日常写实",
orientation: "landscape"
})
});
if (!startRes.ok) {
console.error(` ❌ Start API 错误: ${startRes.status}`);
continue;
}
const startData = await startRes.json();
// Reconstruct session with worldBooks injected
const session = {
id: startData.sessionId,
createdAt: Date.now(),
worldSetting: `现代校园。${scenario.seed}`,
styleGuide: "日常写实",
history: [{
scene: startData.scene,
visitedBeatIds: [startData.scene.entryBeatId || startData.scene.beats[0].id],
exit: { kind: "choice", label: "继续", nextSceneSeed: scenario.seed }
}],
characters: startData.characters,
storyState: startData.storyState,
orientation: "landscape",
worldBooks
};
// Step 2: /api/scene with worldBooks in session (this is where lore injection happens)
const sceneRes = await fetch(`${BASE_URL}/api/scene`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session })
});
if (!sceneRes.ok) {
console.error(` ❌ Scene API 错误: ${sceneRes.status}`);
continue;
}
const sceneData = await sceneRes.json();
const scene = sceneData.scene;
const texts = scene?.beats?.map(b => [b.narration, b.line].filter(Boolean).join(" ")).join(" ") || "";
// 检查是否引用了世界书内容
const constReferenced = texts.includes("县城西郊") || texts.includes("50年");
const triggeredEntries = [];
for (const expected of scenario.expectedTrigger) {
const entry = worldBooks[0].entries.find(e => e.id === expected);
if (entry) {
const referenced = texts.includes("42个座位") || texts.includes("A、B两个窗口") ||
texts.includes("3层") || texts.includes("窗户朝南") ||
texts.includes("盖饭") || texts.includes("面食");
if (referenced) triggeredEntries.push(expected);
}
}
const passed = (scenario.expectedTrigger.length === 0 && triggeredEntries.length === 0) ||
(scenario.expectedTrigger.length > 0 && triggeredEntries.length > 0);
console.log(` ✅ 生成成功 (${texts.length} 字)`);
console.log(` Constant 条目引用: ${constReferenced ? "是" : "否"}`);
console.log(` Triggered 条目触发: ${triggeredEntries.length > 0 ? triggeredEntries.join(", ") : "无"}`);
console.log(` 验证结果: ${passed ? "✓ 通过" : "✗ 失败"}`);
results.push({
seed: scenario.seed,
expectedTrigger: scenario.expectedTrigger,
actualTrigger: triggeredEntries,
constReferenced,
passed
});
} catch (err) {
console.error(` ❌ 请求失败: ${err.message}`);
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
// 输出统计
console.log("\n\n" + "═".repeat(60));
console.log("📊 Task 21 统计结果");
console.log("═".repeat(60));
console.log(`测试场景: ${results.length} / ${scenarios.length}`);
console.log(`通过场景: ${results.filter(r => r.passed).length}`);
console.log(`触发准确率: ${(results.filter(r => r.passed).length / results.length * 100).toFixed(1)}%`);
console.log(`\n期望目标: 触发准确率 ≥90%`);
const reportPath = join(__dirname, "../.spec-workflow/specs/prompt-architecture-redesign/task21-report.json");
await fs.writeFile(reportPath, JSON.stringify({
timestamp: new Date().toISOString(),
summary: {
total: results.length,
passed: results.filter(r => r.passed).length,
accuracy: results.filter(r => r.passed).length / results.length
},
details: results
}, null, 2));
console.log(`\n📄 详细报告已保存: ${reportPath}`);
}
// ──────────────────────────────────────────────────────────────────────
// 主函数
// ──────────────────────────────────────────────────────────────────────
async function main() {
try {
switch (TASK) {
case "18":
await task18_forbiddenWords();
break;
case "20":
await task20_personaInjection();
break;
case "21":
await task21_worldBookTrigger();
break;
default:
console.error(`❌ 未知任务: ${TASK}`);
console.log(`\n可用任务: 18, 20, 21`);
process.exit(1);
}
} catch (err) {
console.error(`\n💥 执行失败: ${err.message}`);
console.error(err.stack);
process.exit(1);
}
}
main();