Add contextual teacher help entry and simplify consultation panel

This commit is contained in:
鲨鱼辣椒
2026-09-22 16:55:07 +08:00
parent 68e676cb62
commit 12d800ec51
11 changed files with 603 additions and 50 deletions

View File

@@ -3,6 +3,7 @@ import type {
TeacherDefinition,
TeacherReference,
TeacherRequest,
TeacherRequestIntent,
TeacherSourceContext,
} from '../../shared/coding-teacher';
import { TeacherError } from './config-client';
@@ -44,7 +45,8 @@ export function compileTeacherContext(
history: TeacherRequest[],
question: string,
references: TeacherReference[],
maxInputTokens = definition.limits.max_input_tokens
maxInputTokens = definition.limits.max_input_tokens,
intent: TeacherRequestIntent = 'question'
) {
const system: TeacherModelMessage = {
role: 'system',
@@ -69,6 +71,15 @@ export function compileTeacherContext(
ref.text
),
'当前问题:\n' + question,
...(intent === 'suggestions'
? [
'本轮交互要求(仅本轮):依据当前来源操作对话和本咨询历史,邀请学生选择一个可以一起讨论的问题。只返回 JSON 对象 {"intro":string,"questions":string[]},不要附加其他文字。intro 是简短、自然的邀请,不超过 400 字;questions 必须有 2–3 个互不重复、具体贴近当前进展的问题,每个不超过 120 字,用学生自己的口吻表达。问题应帮助学生思考或理解方法,而不是替学生安排待办。没有可用上下文时,坦诚说明目前还不了解项目,从学生想做什么、希望谁来用等构思切入;不要编造学生已经完成的功能、作品表现或项目进展,不要生成待办。',
]
: intent === 'guided-help'
? [
'本轮交互要求(仅本轮):学生暂时说不清想问什么。依据当前来源操作对话和本咨询历史,只发起一个具体、容易回答的交流起点,帮助学生开口。用正常、简短的中文文字回答,不返回 JSON,不列出多个问题或一串任务。没有可用上下文时,坦诚从构思切入,不假定学生已经完成了任何功能。',
]
: []),
].join('\n\n'),
};
const sourceMessages = [...source.messages];
@@ -93,7 +104,15 @@ export function compileTeacherContext(
'\n\n'
),
},
{ role: 'assistant' as const, content: request.response },
{
role: 'assistant' as const,
content: [
request.response,
...(request.suggestedQuestions?.length
? ['可以接着聊的问题:\n' + request.suggestedQuestions.map((text) => '- ' + text).join('\n')]
: []),
].join('\n\n'),
},
]),
current,
];

View File

@@ -26,6 +26,7 @@ import { compileTeacherContext } from './context';
import { prepareTeacherModel } from './model-runner';
import { consultationDefinition } from './consultation-role';
import { readTeacherSource } from './source-reader';
import { parseTeacherSuggestions } from './suggestions';
import { subscribeWorksSquareSession } from '../services/works-square-session';
export interface TeacherScope {
@@ -203,6 +204,13 @@ export class CodingTeacherService {
}
async send(scope: TeacherScope, id: string, input: TeacherSend): Promise<TeacherTopic> {
teacherTopicId(input.requestId);
const intent = input.intent === undefined ? 'question' : input.intent;
if (!['question', 'suggestions', 'guided-help'].includes(intent))
throw new TeacherError(422, 'teacher_intent_invalid', '提问方式无效,请重新打开老师后再试。');
if (intent !== 'question' && (
scope.projectId === 'preview' || scope.sourceId !== 'project' || (scope.role ?? 'teacher') !== 'teacher'
))
throw new TeacherError(422, 'teacher_intent_invalid', '这种提问方式只适用于项目里的老师。');
if (input.sourceConversationId !== undefined) teacherTopicId(input.sourceConversationId);
if (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 6000)
throw new TeacherError(422, 'teacher_question_invalid', '请输入 1–6000 字的问题。');
@@ -228,6 +236,7 @@ export class CodingTeacherService {
if (existing) {
if (
existing.text !== input.text ||
(existing.intent ?? 'question') !== intent ||
JSON.stringify(existing.references) !== JSON.stringify(refs) ||
(existing.sourceConversationId ?? undefined) !== (input.sourceConversationId ?? undefined)
)
@@ -301,7 +310,8 @@ export class CodingTeacherService {
topic.requests,
input.text,
references,
model.inputLimit
model.inputLimit,
intent
);
// Reading context and resolving model credentials can yield while a source
// is being deleted. Project consultations must recheck the actual source.
@@ -309,6 +319,7 @@ export class CodingTeacherService {
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
const request = {
id: input.requestId,
intent,
...(input.sourceConversationId ? { sourceConversationId: input.sourceConversationId } : {}),
text: input.text,
references,
@@ -349,6 +360,12 @@ export class CodingTeacherService {
topic.revision++;
this.events.emit(key, structuredClone(topic));
});
this.assertAccount(account);
if (!controller.signal.aborted && intent === 'suggestions') {
const suggestions = parseTeacherSuggestions(current.response);
current.response = suggestions.intro;
current.suggestedQuestions = suggestions.questions;
}
current.status = controller.signal.aborted ? 'cancelled' : 'completed';
} catch (error) {
current.status = controller.signal.aborted ? 'cancelled' : 'failed';
@@ -358,6 +375,10 @@ export class CodingTeacherService {
? error.message
: '老师回复失败,已保留本次问题与收到的内容。';
} finally {
if (intent === 'suggestions' && current.status !== 'completed') {
current.response = '';
delete current.suggestedQuestions;
}
topic.updatedAt = new Date().toISOString();
topic.revision++;
try {

View File

@@ -0,0 +1,33 @@
import { TeacherError } from './config-client';
export interface TeacherSuggestions {
intro: string;
questions: string[];
}
/** Validate model output before it can become interactive student questions. */
export function parseTeacherSuggestions(response: string): TeacherSuggestions {
const invalid = () => new TeacherError(
502,
'teacher_suggestions_invalid',
'老师这次没能整理好可以讨论的问题,请再试一次,或直接告诉老师你的想法。'
);
const text = response.trim();
const fenced = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i);
let parsed: unknown;
try {
parsed = JSON.parse(fenced ? fenced[1] : text);
} catch {
throw invalid();
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw invalid();
const { intro, questions } = parsed as Record<string, unknown>;
if (
typeof intro !== 'string' || !intro.trim() || intro.trim().length > 400 ||
!Array.isArray(questions) || questions.length < 2 || questions.length > 3 ||
questions.some((question) => typeof question !== 'string' || !question.trim() || question.trim().length > 120)
) throw invalid();
const uniqueQuestions = [...new Set(questions.map((question: string) => question.trim()))];
if (uniqueQuestions.length < 2) throw invalid();
return { intro: intro.trim(), questions: uniqueQuestions };
}