项目文件夹

文件
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

321 行
14 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { describe, expect, it } from 'vitest';
import {
buildSimulatorSystemPrompt,
buildNarratorSystemPrompt,
buildSimulatorHistory,
isFirstSceneEntry,
} from '@/lib/pbl/v2/agents/simulator';
import type { PBLChatMessage, PBLAgentThread } from '@/lib/pbl/v2/types';
import { normalizeProjectRuntime, PBL_SIMULATOR_AGENT_ID } from '@/lib/pbl/v2/operations/progress';
import type { PBLProjectV2, PBLMilestone, PBLMicrotask } from '@/lib/pbl/v2/types';
function roleplayMilestone(): PBLMilestone {
return {
id: 'ms-rp',
title: '和林夏聊一聊',
status: 'active',
order: 1,
documents: [],
scenarioStage: 'roleplay',
briefing: '你坐在咖啡馆里,林夏刚到。',
microtasks: [
{
id: 'beat-1',
title: 'beat',
status: 'in_progress',
assignee: 'user',
hints: [],
order: 0,
description: '林夏坐在你对面,刚点了一杯热可可,眼睛有点红。',
completionCriteria: '学习者确认了林夏的情绪',
narration: '你们走进了一家安静的咖啡馆。',
} as unknown as PBLMicrotask,
],
} as unknown as PBLMilestone;
}
function scenarioProject(): PBLProjectV2 {
return {
language: 'zh-CN',
roles: [{ id: 'role-i', type: 'instructor', name: '教练' }],
threads: [{ agentId: 'role-i', messages: [] }],
updatedAt: '2026-06-07T00:00:00.000Z',
scenario: {
setting: '校园咖啡馆的午后',
goal: '练习倾听与共情',
rules: '保持真诚,不评判',
learnerRole: '你是林夏的好朋友',
characters: [
{
id: 'c1',
name: '林夏',
persona: '内向,说话轻声细语',
situation: '这周失恋,情绪低落',
boundaries: '不会突然崩溃大哭',
},
],
},
milestones: [
{
id: 'ms-prep',
title: '准备',
status: 'completed',
order: 0,
documents: [],
scenarioStage: 'prep',
microtasks: [],
},
roleplayMilestone(),
{
id: 'ms-wrap',
title: '收尾',
status: 'locked',
order: 2,
documents: [],
scenarioStage: 'wrapup',
microtasks: [],
},
],
evaluations: [],
engagementEvents: [],
} as unknown as PBLProjectV2;
}
function plainProject(): PBLProjectV2 {
return {
roles: [{ id: 'role-i', type: 'instructor', name: 'Instructor' }],
threads: [{ agentId: 'role-i', messages: [] }],
updatedAt: '2026-06-07T00:00:00.000Z',
milestones: [
{
id: 'ms-1',
title: 'M1',
status: 'active',
order: 0,
documents: [],
microtasks: [
{ id: 'mt-1', title: 'T1', status: 'in_progress', assignee: 'user', hints: [], order: 0 },
],
},
],
evaluations: [],
engagementEvents: [],
} as unknown as PBLProjectV2;
}
describe('PBL v2 — Simulator thread normalization (increment 3)', () => {
it('creates a Simulator thread for a scenario project', () => {
const p = scenarioProject();
expect(p.threads.some((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)).toBe(false);
const changed = normalizeProjectRuntime(p);
expect(changed).toBe(true);
expect(p.threads.some((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)).toBe(true);
});
it('is idempotent — does not duplicate the Simulator thread', () => {
const p = scenarioProject();
normalizeProjectRuntime(p);
normalizeProjectRuntime(p);
expect(p.threads.filter((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)).toHaveLength(1);
});
it('NEVER creates a Simulator thread for an ordinary (non-scenario) project', () => {
const p = plainProject();
normalizeProjectRuntime(p);
expect(p.threads.some((t) => t.agentId === PBL_SIMULATOR_AGENT_ID)).toBe(false);
});
});
describe('PBL v2 — Simulator system prompt (increment 3)', () => {
it('injects the concrete scene + cast (persona/situation/boundaries) and the project language', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
expect(prompt).toContain('校园咖啡馆的午后');
expect(prompt).toContain('林夏');
expect(prompt).toContain('这周失恋,情绪低落');
expect(prompt).toContain('保持真诚,不评判'); // rules
expect(prompt).toContain('你是林夏的好朋友'); // learnerRole
expect(prompt).toContain('和林夏聊一聊'); // current scene title
expect(prompt).toContain('zh-CN'); // language lock
});
it('carries zero teaching rules + forbids narrating/evaluating/guiding — it is a character, not a coach/narrator', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
expect(prompt).toMatch(/NOT the narrator, a coach, a teacher, an examiner, or a judge/i);
expect(prompt).toMatch(/Evaluate, praise, grade/i); // no judging the learner
expect(prompt).toMatch(/Ask the learner to explain, justify/i); // no quizzing reasoning
expect(prompt).toMatch(/participant/i); // pursues its own goals, not a facilitator
expect(prompt).toMatch(/never.*AI/i);
});
it('does NOT feed the beat completionCriteria (the pedagogical goal) into the character — that is what turned it into a coach', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
expect(prompt).not.toMatch(/Private direction/i);
expect(prompt).not.toContain('学习者确认了林夏的情绪'); // completionCriteria must stay out of the character's mouth-prompt
});
it('grounds the character in the beat established facts (narration + description) so it cannot invent/contradict', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
expect(prompt).toMatch(/Established facts/i);
expect(prompt).toContain('林夏坐在你对面,刚点了一杯热可可'); // from description
expect(prompt).toMatch(/never invent or contradict/i);
});
it('B1: injects the beat characterObjective as a PRIVATE aim (never narrated/coached) when authored', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
(ms.microtasks[0] as unknown as PBLMicrotask).characterObjective = '想知道你是否真的在乎';
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
expect(prompt).toContain('想知道你是否真的在乎');
expect(prompt).toMatch(/private aim/i);
expect(prompt).toMatch(/NEVER announce it, narrate it, evaluate/i);
});
it('B1: omits the private-aim block entirely when the beat has no characterObjective', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
expect(prompt).not.toMatch(/private aim this beat/i);
});
});
describe('PBL v2 — Scene narrator system prompt (increment 3 — live narration)', () => {
it('is a pure third-person narrator: not a character, not a coach, with a NONE sentinel', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildNarratorSystemPrompt(p, ms, ms.microtasks[0]);
expect(prompt).toMatch(/Scene Narrator/i);
expect(prompt).toMatch(/NOT a character and NOT a coach/i);
expect(prompt).toMatch(/never put a character's WORDS in their mouth/i);
expect(prompt).toContain('NONE'); // silent-when-nothing-happens sentinel
});
it('is grounded in the scene + beat facts and bounded (no skipping ahead)', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildNarratorSystemPrompt(p, ms, ms.microtasks[0]);
expect(prompt).toContain('校园咖啡馆的午后'); // setting
expect(prompt).toContain('林夏坐在你对面,刚点了一杯热可可'); // established facts (description)
expect(prompt).toContain('林夏'); // cast name for reference
expect(prompt).toMatch(/NEVER skip ahead/i);
});
it('lets the narrator describe characters’ non-verbal actions/reactions but NEVER voice/summarize their speech (the fix for the narrator eating the character’s words)', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildNarratorSystemPrompt(p, ms, ms.microtasks[0]);
// actions / reactions / body language ARE the narrator's job
expect(prompt).toMatch(/NON-VERBAL behaviour/i);
expect(prompt).toMatch(/我皱了皱眉/); // the example: a character cannot self-narrate this
// but it must never voice OR paraphrase what a character SAYS
expect(prompt).toMatch(/never summarize or paraphrase WHAT THEY SAY/i);
expect(prompt).toMatch(/their own turn/i);
// the dynamic cast line repeats the precise boundary with the actual names
expect(prompt).toMatch(/MAY describe their visible actions, reactions/i);
expect(prompt).toMatch(/NEVER speak for them or summarize\/paraphrase what they SAY/i);
});
it('returns just the base prompt for a non-scenario project (defensive)', () => {
const plain = plainProject();
const ms = plain.milestones[0];
const prompt = buildNarratorSystemPrompt(plain, ms, ms.microtasks[0]);
expect(prompt).toMatch(/Scene Narrator/i);
expect(prompt).not.toContain('## The scene');
});
});
describe('PBL v2 — buildSimulatorHistory audience split (role-bleed root-cause fix)', () => {
const thread = (): PBLAgentThread =>
({
agentId: PBL_SIMULATOR_AGENT_ID,
messages: [
{ id: 's1', roleType: 'system', content: '她皱了皱眉,身体往后靠了靠。', ts: 't' },
{ id: 'c1', roleType: 'simulator', content: '你这话什么意思?', ts: 't' },
{ id: 'u1', roleType: 'user', content: '我只是关心你。', ts: 't' },
],
}) as unknown as PBLAgentThread;
it('EXCLUDES system narration from the CHARACTER history (so it cannot imitate the 3rd-person prose — the root cause)', () => {
const out = buildSimulatorHistory(thread(), 'character');
expect(out).toEqual([
{ role: 'assistant', content: '你这话什么意思?' },
{ role: 'user', content: '我只是关心你。' },
]);
expect(JSON.stringify(out)).not.toContain('她皱了皱眉');
});
it('INCLUDES system narration (as Scene context) in the DIRECTOR history so scene-keeping stays continuous', () => {
const out = buildSimulatorHistory(thread(), 'director');
expect(out).toContainEqual({
role: 'system',
content: '(Scene: 她皱了皱眉,身体往后靠了靠。)',
});
expect(out).toContainEqual({ role: 'assistant', content: '你这话什么意思?' });
expect(out).toContainEqual({ role: 'user', content: '我只是关心你。' });
});
it('returns empty for an undefined thread (both audiences)', () => {
expect(buildSimulatorHistory(undefined, 'character')).toEqual([]);
expect(buildSimulatorHistory(undefined, 'director')).toEqual([]);
});
});
describe('PBL v2 — character prompt forbids 3rd-person / narration imitation', () => {
it('explicitly bans writing in 3rd person about itself and imitating the narrator prose in history', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildSimulatorSystemPrompt(p, ms, ms.microtasks[0]);
expect(prompt).toMatch(/NEVER write in the third person about yourself/i);
expect(prompt).toMatch(/NOT your style and you must NOT copy it/i);
});
});
describe('PBL v2 — director-narrator is pure narration (act model: no advance role)', () => {
it('narrator prompt no longer carries any beat-advance tool/role (the act model removed it)', () => {
const p = scenarioProject();
const ms = p.milestones.find((m) => m.scenarioStage === 'roleplay')!;
const prompt = buildNarratorSystemPrompt(p, ms, ms.microtasks[0]);
// The act model: the learner ends an act manually; the narrator only narrates.
expect(prompt).not.toMatch(/advance_scene_beat/);
expect(prompt).not.toMatch(/Behind the scenes/i);
// It still runs after the character and stays a pure third-person narrator.
expect(prompt).toMatch(/AFTER the character has spoken/i);
expect(prompt).toMatch(/NOT a character and NOT a coach/i);
});
});
describe('PBL v2 — isFirstSceneEntry (inc4d openingLine gate)', () => {
const msg = (roleType: PBLChatMessage['roleType'], content = 'x'): PBLChatMessage => ({
id: `m-${Math.random().toString(16).slice(2)}`,
roleType,
content,
ts: 't',
});
it('is true for a truly empty thread', () => {
expect(isFirstSceneEntry([])).toBe(true);
expect(isFirstSceneEntry(undefined)).toBe(true);
});
it('stays true when only a system divider is present (prep→roleplay handover)', () => {
// The handover pushes a `system` divider into the simulator thread BEFORE
// the greeting runs — the authored openingLine must STILL be delivered.
expect(isFirstSceneEntry([msg('system', '[MILESTONE_DIVIDER]阶段推进:准备 → 第一幕')])).toBe(
true,
);
});
it('is false once the scene has actually started (a character or learner spoke)', () => {
expect(isFirstSceneEntry([msg('system'), msg('simulator')])).toBe(false);
expect(isFirstSceneEntry([msg('system'), msg('user')])).toBe(false);
});
});