Files
infiplot-web/scripts/collect-dialogue-samples.mjs
Zonghao Yuan 0e4c2ebef4 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>
2026-06-18 18:05:38 +08:00

240 lines
8.3 KiB
JavaScript

/**
* Task 19: 收集3组多角色对话场景样本
* 用于人工盲测评分(有个性/生活化,1-5分)
*
* 策略:使用不同世界设定和角色组合,生成多场景(start+2次scene续场),
* 确保对话足够长且有分支选择。
*/
const BASE_URL = "https://infiplot.y-9e6.workers.dev";
const scenarios = [
{
id: "A",
name: "校园日常·三角关系",
worldSetting: "现代日本高中校园。樱花季的放学时刻,三个性格迥异的角色在学校天台展开一场关于暗恋对象的对话。故事聚焦于人物间的微妙情感和误解。",
styleGuide: "anime illustration, soft pastel colors, warm lighting, gentle character expressions, school rooftop backdrop with cherry blossoms"
},
{
id: "B",
name: "悬疑推理·密室对峙",
worldSetting: "1930年代上海法租界。一栋老洋房的书房里,三位嫌疑人被侦探召集。一场凶杀案的真相即将揭晓,每个人都有秘密。紧张的心理博弈在昏暗的灯光下展开。",
styleGuide: "noir detective style, muted sepia tones, dramatic shadows, 1930s Shanghai architecture, dim lamp lighting"
},
{
id: "C",
name: "奇幻冒险·酒馆夜话",
worldSetting: "中世纪奇幻世界的冒险者酒馆。三位刚完成一次失败任务的冒险者在角落的桌子旁借酒浇愁。精灵弓手在反思自己的失误,矮人战士在安慰同伴,人类法师则在计划下一步。他们之间有深厚的友情,也有未说出口的分歧。",
styleGuide: "fantasy tavern, warm candlelight, medieval wooden interior, mugs of ale, adventuring gear on table"
}
];
async function generateScenario(scenario) {
console.log(`\n${"═".repeat(60)}`);
console.log(`🎬 场景 ${scenario.id}: ${scenario.name}`);
console.log(`${"═".repeat(60)}\n`);
const allBeats = [];
// Step 1: Start session
console.log(" [1/3] 开始会话...");
const startRes = await fetch(`${BASE_URL}/api/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
worldSetting: scenario.worldSetting,
styleGuide: scenario.styleGuide,
orientation: "landscape"
})
});
if (!startRes.ok) {
const err = await startRes.text().catch(() => "");
console.error(` ❌ Start 失败: ${startRes.status} ${err.slice(0, 200)}`);
return null;
}
const startData = await startRes.json();
const scene1 = startData.scene;
allBeats.push({ sceneNum: 1, scene: scene1 });
console.log(` ✅ 场景1: ${scene1.beats.length} beats`);
// Build session for next scene
let session = {
id: startData.sessionId,
createdAt: Date.now(),
worldSetting: scenario.worldSetting,
styleGuide: scenario.styleGuide,
orientation: "landscape",
storyState: startData.storyState,
characters: startData.characters,
history: [{
scene: scene1,
visitedBeatIds: scene1.beats.map(b => b.id),
exit: findFirstExit(scene1)
}]
};
// Step 2: Generate scene 2
console.log(" [2/3] 生成续场景...");
await sleep(3000);
const scene2Res = await fetch(`${BASE_URL}/api/scene`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session })
});
if (scene2Res.ok) {
const scene2Data = await scene2Res.json();
const scene2 = scene2Data.scene;
allBeats.push({ sceneNum: 2, scene: scene2 });
console.log(` ✅ 场景2: ${scene2.beats.length} beats`);
// Update session
session.storyState = scene2Data.storyState;
session.characters = scene2Data.characters;
session.history.push({
scene: scene2,
visitedBeatIds: scene2.beats.map(b => b.id),
exit: findFirstExit(scene2)
});
// Step 3: Generate scene 3
console.log(" [3/3] 生成第三场景...");
await sleep(3000);
const scene3Res = await fetch(`${BASE_URL}/api/scene`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session })
});
if (scene3Res.ok) {
const scene3Data = await scene3Res.json();
const scene3 = scene3Data.scene;
allBeats.push({ sceneNum: 3, scene: scene3 });
console.log(` ✅ 场景3: ${scene3.beats.length} beats`);
} else {
console.log(` ⚠️ 场景3 失败: ${scene3Res.status}`);
}
} else {
console.log(` ⚠️ 场景2 失败: ${scene2Res.status}`);
}
return { scenario, scenes: allBeats };
}
function findFirstExit(scene) {
for (const beat of scene.beats) {
if (beat.next?.type === "choice" && beat.next.choices?.length > 0) {
const choice = beat.next.choices[0];
if (choice.effect?.kind === "change-scene") {
return {
kind: "choice",
choiceId: choice.id,
label: choice.label,
nextSceneSeed: choice.effect.nextSceneSeed
};
}
}
}
return { kind: "choice", choiceId: "fallback", label: "继续", nextSceneSeed: "故事继续" };
}
function formatSceneForDoc(sceneData, sceneNum) {
const { scene } = sceneData;
let md = `### 第${sceneNum}幕\n\n`;
for (const beat of scene.beats) {
// Narration
if (beat.narration) {
md += `*${beat.narration}*\n\n`;
}
// Dialogue
if (beat.speaker && beat.line) {
const delivery = beat.lineDelivery ? ` _(${beat.lineDelivery})_` : "";
md += `**${beat.speaker}**:「${beat.line}${delivery}\n\n`;
}
// Choices
if (beat.next?.type === "choice" && beat.next.choices?.length > 0) {
md += `---\n📌 **选择分支:**\n`;
for (const c of beat.next.choices) {
const effect = c.effect?.kind === "change-scene"
? `→ 换场: ${c.effect.nextSceneSeed}`
: c.effect?.kind === "advance-beat"
? `→ 跳转: ${c.effect.targetBeatId}`
: "";
md += `- [ ] ${c.label} ${effect ? `*(${effect})*` : ""}\n`;
}
md += `\n`;
}
}
return md;
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function main() {
console.log("📋 Task 19: 收集对白质量盲测样本");
console.log(`📍 目标环境: ${BASE_URL}\n`);
const results = [];
for (const scenario of scenarios) {
const result = await generateScenario(scenario);
if (result) results.push(result);
await sleep(2000);
}
// Generate markdown document
let doc = `# 对白质量盲测样本\n\n`;
doc += `> 生成时间: ${new Date().toISOString()}\n`;
doc += `> 环境: ${BASE_URL}\n`;
doc += `> 模型: gemini-3.1-flash-lite-preview\n\n`;
doc += `## 评分标准\n\n`;
doc += `请对每组场景的对白质量进行评分(1-5分):\n\n`;
doc += `| 维度 | 1分 | 3分 | 5分 |\n`;
doc += `|------|-----|-----|-----|\n`;
doc += `| **有个性** | 所有角色说话一个味 | 能区分但不突出 | 角色鲜明、一看就知道谁说的 |\n`;
doc += `| **生活化** | 像机器生成的套话 | 基本通顺但略僵 | 自然流畅、像真人会说的话 |\n\n`;
doc += `---\n\n`;
for (const result of results) {
doc += `## 场景 ${result.scenario.id}: ${result.scenario.name}\n\n`;
doc += `> 设定: ${result.scenario.worldSetting.slice(0, 80)}...\n\n`;
for (const sceneData of result.scenes) {
doc += formatSceneForDoc(sceneData, sceneData.sceneNum);
}
doc += `### 评分\n\n`;
doc += `| 维度 | 评分 (1-5) | 备注 |\n`;
doc += `|------|-----------|------|\n`;
doc += `| 有个性 | | |\n`;
doc += `| 生活化 | | |\n\n`;
doc += `---\n\n`;
}
doc += `## 汇总\n\n`;
doc += `| 场景 | 有个性 | 生活化 | 平均 |\n`;
doc += `|------|--------|--------|------|\n`;
doc += `| A | | | |\n`;
doc += `| B | | | |\n`;
doc += `| C | | | |\n`;
doc += `| **总平均** | | | |\n\n`;
doc += `> 期望目标: 平均分 ≥ 4/5\n`;
// Save document
const { writeFile } = await import("node:fs/promises");
const outPath = "G:\\infiplot\\.spec-workflow\\specs\\prompt-architecture-redesign\\task19-dialogue-samples.md";
await writeFile(outPath, doc, "utf-8");
console.log(`\n\n✅ 盲测文档已保存: ${outPath}`);
// Also save raw JSON for reference
const jsonPath = "G:\\infiplot\\.spec-workflow\\specs\\prompt-architecture-redesign\\task19-raw-scenes.json";
await writeFile(jsonPath, JSON.stringify(results, null, 2), "utf-8");
console.log(`📄 原始数据已保存: ${jsonPath}`);
}
main().catch(console.error);