feat: align teacher guidance with persistent discussion tools
This commit is contained in:
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CodingTeacherService, type TeacherScope } from '../../electron/coding-teacher/service';
|
||||
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
|
||||
import { compileTeacherContext, estimateTeacherTokens, sourceContext } from '../../electron/coding-teacher/context';
|
||||
import { TEACHER_BEHAVIOR_PROMPT } from '../../electron/coding-teacher/behavior-prompt';
|
||||
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
|
||||
import { TeacherError } from '../../electron/coding-teacher/config-client';
|
||||
import { consultationDefinition } from '../../electron/coding-teacher/consultation-role';
|
||||
@@ -985,6 +986,10 @@ describe('teacher context and wire contract', () => {
|
||||
expect(selected.cursor).toEqual(snapshot.cursor);
|
||||
});
|
||||
it('trims old source messages but retains instructions, Skill, explicit quote and question', () => {
|
||||
const references = [{ kind: 'code' as const, text: 'count += 1' }];
|
||||
const budget = estimateTeacherTokens(compileTeacherContext(
|
||||
definition, context, [], '为什么这样?', references
|
||||
).messages);
|
||||
const compiled = compileTeacherContext(
|
||||
definition,
|
||||
{
|
||||
@@ -993,13 +998,15 @@ describe('teacher context and wire contract', () => {
|
||||
},
|
||||
[],
|
||||
'为什么这样?',
|
||||
[{ kind: 'code', text: 'count += 1' }],
|
||||
1200
|
||||
references,
|
||||
budget
|
||||
);
|
||||
const text = JSON.stringify(compiled.messages);
|
||||
expect(text).toContain('通过问题引导思考');
|
||||
expect(text).toContain('使用具体的小例子');
|
||||
expect(text).toContain('count += 1');
|
||||
expect(compiled.messages[0].content).toContain(TEACHER_BEHAVIOR_PROMPT);
|
||||
expect(compiled.includedSourceMessageIds).toEqual(['source-user']);
|
||||
expect(compiled.omittedMessages).toBe(1);
|
||||
expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(9000), [])).toThrow(
|
||||
'超过上下文预算'
|
||||
@@ -1169,6 +1176,9 @@ describe('structured teacher service integration', () => {
|
||||
requestId: crypto.randomUUID(), text, presentation: 'discussion-v1', sourceConversationId: f.scope.sourceId, discussion,
|
||||
});
|
||||
const pending = await send('想养只小狗');
|
||||
const modelMessages = f.run.mock.calls[0][0] as Array<{ role: string; content: string }>;
|
||||
expect(modelMessages[0].content).toContain(TEACHER_BEHAVIOR_PROMPT);
|
||||
expect(modelMessages.some(message => message.role === 'system' && message.content.includes('本轮界面协议'))).toBe(true);
|
||||
expect(pending.requests[0].response).toBe('');
|
||||
expect(pending.discussion).toBeUndefined();
|
||||
f.finish();
|
||||
|
||||
143
tests/unit/teacher-guidance.test.ts
Normal file
143
tests/unit/teacher-guidance.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import examples from '../fixtures/teacher-guidance-examples.json';
|
||||
import { TEACHER_BEHAVIOR_PROMPT } from '../../electron/coding-teacher/behavior-prompt';
|
||||
import { compileTeacherContext, estimateTeacherTokens } from '../../electron/coding-teacher/context';
|
||||
import { consultationDefinition } from '../../electron/coding-teacher/consultation-role';
|
||||
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';
|
||||
|
||||
const definition: TeacherDefinition = {
|
||||
schema_version: 1, teacher_id: 'coding-teacher', 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 }],
|
||||
model: { model_id: 'configured-model', reasoning_choice: { mode: 'default' } },
|
||||
limits: { max_input_tokens: 16000, max_output_tokens: 2000 },
|
||||
};
|
||||
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).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` };
|
||||
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`);
|
||||
});
|
||||
|
||||
it.each(['question', 'suggestions', 'guided-help', 'check-in'] as const)('applies teacher guidance to %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.split(TEACHER_BEHAVIOR_PROMPT)).toHaveLength(2);
|
||||
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('leaves the friend persona and enabled teaching material isolated', () => {
|
||||
const friend = consultationDefinition(definition, 'friend');
|
||||
const compiled = compileTeacherContext(friend, source, [], '你觉得呢', []);
|
||||
expect(compiled.messages[0].content).toContain(friend.system_prompt);
|
||||
expect(compiled.messages[0].content).not.toContain(TEACHER_BEHAVIOR_PROMPT);
|
||||
expect(compiled.messages[0].content).not.toContain('启用的教学补充');
|
||||
});
|
||||
|
||||
it('keeps the structured guided-help format authoritative after behavior and cloud supplements', () => {
|
||||
const instructions = discussionInstructions(topic());
|
||||
const compiled = compileTeacherContext(definition, source, [], '我说不清', [], undefined, 'guided-help', instructions);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('representative teaching examples against the real discussion contract', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user