refactor(teacher): retire discussion components and keep text replies

This commit is contained in:
鲨鱼辣椒 committed 2026-09-28 13:54:55 +08:00
1 parent ae0f72d8fa
commit 998796d77c
32 files changed
+1060 -1794

No files matched your search

+45 -119
View File
@@ -1,14 +1,13 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import examples from '../fixtures/teacher-guidance-examples.json';
const TEACHER_BEHAVIOR_PROMPT = '你是麦洛的创作老师';
import { compileTeacherContext, estimateTeacherTokens, teacherHistoryMessages } from '../../electron/coding-teacher/context';
import { applyDiscussionReply, discussionInstructions, editDiscussion, validateDiscussionContext } from '../../electron/coding-teacher/discussion';
import { parseTeacherDiscussionReply } from '../../shared/teacher-discussion';
import type { TeacherDefinition, TeacherDiscussionAction, TeacherDiscussionContext, TeacherRequest, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
import { teacherReplyInstructions } from '../../electron/coding-teacher/reply';
import { parseTeacherReply } from '../../shared/teacher-reply';
import type { TeacherDefinition, TeacherRequest, TeacherSourceContext } from '../../shared/coding-teacher';
const definition: TeacherDefinition = {
schema_version: 1, teacher_id: 'coding-teacher', name: '老师', description: '', avatar_id: 'avatar-01',
schema_version: 1, teacher_id: 'published-agent', name: '老师', description: '', avatar_id: 'avatar-01',
welcome_message: '一起想一想', suggested_questions: [], system_prompt: '运营发布的老师说明',
skills: [{ id: 'extra', name: '补充', description: '', instructions_markdown: '启用的教学补充', enabled: true },
{ id: 'off', name: '停用', description: '', instructions_markdown: '不应加载的资料', enabled: false }],
@@ -19,74 +18,47 @@ const source: TeacherSourceContext = {
messages: [{ id: 'source', role: 'user', text: '我想做自己的作品' }],
cursor: { workerGeneration: 1, seq: 1 }, capturedAt: 'now',
};
function topic(): TeacherTopic {
return { id: 'topic', schemaVersion: 1, revision: 1, accountId: 'account', projectId: 'project',
sourceConversationId: 'source', version: 1, definition, createdAt: 'now', updatedAt: 'now', requests: [] };
}
function action(owner: TeacherTopic, value: TeacherDiscussionAction['action']) {
editDiscussion(owner, { toolId: owner.discussion!.id, revision: owner.discussion!.revision, action: value });
}
function turn(owner: TeacherTopic, text: string, response: unknown, extra: Partial<TeacherDiscussionContext> = {}) {
const context = owner.discussion?.status === 'active'
? validateDiscussionContext(owner, { toolId: owner.discussion.id, revision: owner.discussion.revision, ...extra }) : undefined;
const compiled = compileTeacherContext(definition, source, owner.requests, text, [], undefined, 'question', discussionInstructions(owner, context));
expect(compiled.messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(compiled.includedSourceMessageIds).toEqual(['source']);
expect(estimateTeacherTokens(compiled.messages)).toBeLessThanOrEqual(definition.limits.max_input_tokens);
const request: TeacherRequest = { id: `turn-${owner.requests.length}`, text, references: [], createdAt: 'now',
sourceCursor: source.cursor, sourceCapturedAt: 'now', includedSourceMessageIds: compiled.includedSourceMessageIds,
omittedMessages: compiled.omittedMessages, status: 'running', response: '', presentation: 'discussion-v1', discussionContext: context };
applyDiscussionReply(owner, request, JSON.stringify(response));
expect(request.discussionError).toBeUndefined();
request.status = 'completed';
owner.requests.push(request);
return request;
}
describe('teacher behavior wiring and per-request formats', () => {
it('does not duplicate the same teaching baseline when operations publishes it', () => {
const configured = { ...definition, system_prompt: `\n${TEACHER_BEHAVIOR_PROMPT}\n` };
describe('published guidance and text reply wiring', () => {
it.each(['question', 'suggestions', 'guided-help', 'check-in'] as const)(
'preserves cloud persona and enabled skills for %s without adding a fixed identity or writing style', intent => {
const before = structuredClone(definition);
const instructions = intent === 'check-in' ? undefined : teacherReplyInstructions();
const compiled = compileTeacherContext(definition, source, [], '帮我想一想', [], undefined, intent, instructions);
const system = compiled.messages[0].content;
expect(system).toContain(definition.system_prompt);
expect(system).toContain('启用的教学补充');
expect(system).not.toContain('不应加载的资料');
expect(compiled.messages.map(message => message.content).join('\n'))
.not.toMatch(/你是麦洛的创作老师|Alice|最多120字|不超过400字|2–3|学生自己的口吻/);
expect(definition).toEqual(before);
expect(compiled.messages.at(-1)?.role).toBe(intent === 'check-in' ? 'system' : 'user');
if (instructions) expect(compiled.messages.at(-2)).toEqual({ role: 'system', content: instructions });
if (intent === 'suggestions' || intent === 'guided-help') {
expect(compiled.messages.at(-1)?.content).toContain('推荐一个具体切入点');
}
}
);
it('does not duplicate a persona that operations publishes', () => {
const persona = '运营发布的独特身份与前缀';
const configured = { ...definition, system_prompt: `\n${persona}\n` };
const compiled = compileTeacherContext(configured, source, [], '继续聊', []);
expect(compiled.messages[0].content.split(TEACHER_BEHAVIOR_PROMPT)).toHaveLength(2);
expect(compiled.messages[0].content).toContain('启用的教学补充');
expect(configured.system_prompt).toBe(`\n${TEACHER_BEHAVIOR_PROMPT}\n`);
expect(compiled.messages[0].content.split(persona)).toHaveLength(2);
expect(configured.system_prompt).toBe(`\n${persona}\n`);
});
it.each(['question', 'suggestions', 'guided-help', 'check-in'] as const)('preserves the published persona for %s without mutating the cloud definition', intent => {
const before = structuredClone(definition);
const compiled = compileTeacherContext(definition, source, [], '帮我想一想', [], undefined, intent);
const system = compiled.messages[0].content;
expect(system).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(system).toContain(definition.system_prompt);
expect(system).toContain('启用的教学补充');
expect(system).not.toContain('不应加载的资料');
expect(definition).toEqual(before);
expect(compiled.messages.at(-1)?.role).toBe(intent === 'check-in' ? 'system' : 'user');
if (intent === 'suggestions') expect(compiled.messages.at(-1)?.content).toContain('{"intro":string,"questions":string[]}');
expect(compiled.messages.some(message => message.content.includes('本轮界面协议'))).toBe(false);
});
it('uses a server-defined friend persona with its own skills and the same project tools', () => {
const friend = { ...definition, teacher_id: 'published-friend', name: '朋友', system_prompt: '我是运营发布的朋友,只聊作品体验。' };
const compiled = compileTeacherContext(friend, source, [], '你觉得呢', [], undefined, 'question', undefined, true);
expect(compiled.messages[0].content).toContain(friend.system_prompt);
expect(compiled.messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
it('keeps the same configured skills and read-only project access for any published identity', () => {
const configured = { ...definition, teacher_id: 'published-friend', system_prompt: '运营发布的作品体验伙伴。' };
const instructions = teacherReplyInstructions();
const compiled = compileTeacherContext(configured, source, [], '你觉得呢', [], undefined, 'question', instructions, true);
expect(compiled.messages[0].content).toContain(configured.system_prompt);
expect(compiled.messages[0].content).toContain('启用的教学补充');
expect(compiled.messages[0].content).toContain('你可以通过只读工具');
});
it('keeps the structured guided-help format alongside project read access and teaching guidance', () => {
const instructions = discussionInstructions(topic());
const compiled = compileTeacherContext(definition, source, [], '我说不清', [], undefined, 'guided-help', instructions, true);
expect(compiled.messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
expect(compiled.messages[0].content).toContain('你可以通过只读工具');
expect(compiled.messages[0].content).not.toContain('没有项目读取工具');
expect(compiled.messages.at(-2)).toEqual({ role: 'system', content: instructions });
expect(compiled.messages.at(-1)?.content).toContain('按本轮界面协议返回');
expect(compiled.messages.at(-1)?.content).not.toContain('不返回 JSON');
});
it('budgets readable history without fabricating a student turn for check-ins or losing suggested questions', () => {
it('budgets readable history while retaining the latest answer and suggestions, without exposing archived tools or raw failures', () => {
const saved: TeacherRequest = {
id: 'check-in', intent: 'check-in', text: '内部主动触发', response: '刚才的作品有新进展。',
references: [], createdAt: 'now', sourceCursor: source.cursor, sourceCapturedAt: 'now',
@@ -96,6 +68,8 @@ describe('teacher behavior wiring and per-request formats', () => {
...saved, id: 'latest', intent: 'suggestions', text: '一起聊什么?',
response: '先理解玩家想做什么。' + '需要考虑的作品细节。'.repeat(1200),
suggestedQuestions: ['怎样判断这个体验是否有趣?'],
presentation: 'discussion-v1', discussionSnapshot: { kind: 'ideas', title: '旧组件仅留档' },
unparsedResponse: '不应重新发送的原始损坏协议', replyParseError: '历史解析错误',
}];
const original = structuredClone(history);
const readable = teacherHistoryMessages(history);
@@ -103,9 +77,9 @@ describe('teacher behavior wiring and per-request formats', () => {
'teacher:check-in:assistant', 'teacher:latest:user', 'teacher:latest:assistant',
]);
expect(readable.at(-1)?.text).toContain('怎样判断这个体验是否有趣?');
expect(readable.some(message => message.text.includes('内部主动触发'))).toBe(false);
expect(JSON.stringify(readable)).not.toMatch(/内部主动触发|旧组件仅留档|原始损坏协议/);
const instructions = discussionInstructions(topic());
const instructions = teacherReplyInstructions();
const fixed = compileTeacherContext(definition, source, [], '接着刚才的问题聊', [], undefined, 'question', instructions, true);
const budget = estimateTeacherTokens(fixed.messages) + 1200;
const compiled = compileTeacherContext(definition, source, history, '接着刚才的问题聊', [], budget, 'question', instructions, true);
@@ -122,60 +96,12 @@ describe('teacher behavior wiring and per-request formats', () => {
});
});
describe('representative teaching examples against the real discussion contract', () => {
describe('historical reply compatibility examples', () => {
const responses = [examples.pet, examples.pet.followup, examples.pet.structure, examples.pet.paused,
examples.pet.finished, examples.garden, examples.garden.followup, examples.shooter, examples.cake];
it.each(responses)('parses the example: $response.reply', ({ response }) => {
const parsed = parseTeacherDiscussionReply(JSON.stringify(response));
expect(parsed.toolError).toBeUndefined();
expect(parsed.reply).toBe(response.reply);
expect(parsed.tool ?? null).toEqual(response.tool);
expect(parsed.quickReplies).toEqual(response.quickReplies);
});
it('continues the pet structure across discussion, pause, finish and return without accepting a teacher candidate', () => {
const owner = topic();
turn(owner, examples.pet.student, examples.pet.response);
expect(owner.discussion?.status).toBe('offered');
const identity = owner.discussion!.id;
action(owner, 'enter');
turn(owner, examples.pet.followup.student, examples.pet.followup.response, { focusId: 'dog' });
expect(owner.discussion?.content).toEqual(examples.pet.followup.response.tool);
turn(owner, examples.pet.structure.studentAction, examples.pet.structure.response, { transition: 'structure' });
expect(owner.discussion?.previousIdeas).toEqual(examples.pet.followup.response.tool);
expect(owner.discussion?.content).toEqual(examples.pet.structure.response.tool);
const structure = structuredClone(owner.discussion!.content);
action(owner, 'pause');
turn(owner, examples.pet.paused.student, examples.pet.paused.response);
expect(owner.discussion?.content).toEqual(structure);
action(owner, 'resume');
action(owner, 'finish');
turn(owner, examples.pet.finished.student, examples.pet.finished.response);
expect(owner.discussion).toMatchObject({ id: identity, status: 'finished', content: structure });
action(owner, 'resume');
action(owner, 'back-ideas');
expect(owner.discussion).toMatchObject({ id: identity, status: 'active', content: examples.pet.followup.response.tool });
expect(owner.discussion!.content).not.toHaveProperty('confirmed');
});
it('changes only the discussed garden outcome while retaining the condition, other outcome and edges', () => {
const owner = topic();
turn(owner, examples.garden.student, examples.garden.response);
action(owner, 'enter');
const before = structuredClone(owner.discussion!);
turn(owner, examples.garden.followup.student, examples.garden.followup.response, { focusId: 'wait' });
expect(owner.discussion?.id).toBe(before.id);
expect(owner.discussion?.content).toEqual(examples.garden.followup.response.tool);
expect(owner.requests[0].discussionSnapshot).toEqual(examples.garden.response.tool);
});
it('keeps a comparison as aligned discussion data and allows a creative project to stay in plain conversation', () => {
const compared = topic();
turn(compared, examples.shooter.student, examples.shooter.response);
expect(compared.discussion?.content).toEqual(examples.shooter.response.tool);
const plain = topic();
const request = turn(plain, examples.cake.student, examples.cake.response);
expect(plain.discussion).toBeUndefined();
expect(request.suggestedQuestions).toEqual(examples.cake.response.quickReplies);
it.each(responses)('retains text and suggestions while discarding historical component data: $response.reply', ({ response }) => {
expect(parseTeacherReply(JSON.stringify(response))).toEqual({
reply: response.reply, quickReplies: response.quickReplies,
});
});
});