feat: integrate pixel teacher presence and resilient classroom preview
This commit is contained in:
@@ -61,8 +61,10 @@ export function compileTeacherContext(
|
||||
].join('\n\n'),
|
||||
};
|
||||
const current: TeacherModelMessage = {
|
||||
role: 'user',
|
||||
content: [
|
||||
role: intent === 'check-in' ? 'system' : 'user',
|
||||
content: intent === 'check-in'
|
||||
? '本轮是老师定时主动关心,不是学生提问。依据来源操作对话中已完成的文字和老师咨询历史,自然地说一段简短中文关心、具体建议或思考引导,约 120 字,最多问一个问题,不要求学生立即回答。只围绕已有证据,不重复刚说过的内容,不整理待办、不替学生作决定;没有实际看到或操作作品,不能假装看到了画面、运行或试玩过作品。直接说给学生听,不提定时检查、系统触发等技术过程。'
|
||||
: [
|
||||
...references.map(
|
||||
(ref) =>
|
||||
'明确引用' +
|
||||
@@ -97,13 +99,13 @@ export function compileTeacherContext(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...exchanges.flatMap((request) => [
|
||||
{
|
||||
...exchanges.flatMap((request): TeacherModelMessage[] => [
|
||||
...(request.intent === 'check-in' ? [] : [{
|
||||
role: 'user' as const,
|
||||
content: [...request.references.map((ref) => '明确引用:\n' + ref.text), request.text].join(
|
||||
'\n\n'
|
||||
),
|
||||
},
|
||||
}]),
|
||||
{
|
||||
role: 'assistant' as const,
|
||||
content: [
|
||||
@@ -121,8 +123,7 @@ export function compileTeacherContext(
|
||||
omitted++;
|
||||
}
|
||||
while (estimateTeacherTokens(build()) > maxInputTokens && exchanges.length > 1) {
|
||||
exchanges.shift();
|
||||
omitted += 2;
|
||||
omitted += exchanges.shift()?.intent === 'check-in' ? 1 : 2;
|
||||
}
|
||||
const messages = build();
|
||||
if (estimateTeacherTokens(messages) > maxInputTokens)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { readdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
@@ -6,12 +6,15 @@ import type { CodingProjectService } from '../coding-projects/project-service';
|
||||
import type { CodingConversationRuntime } from '../coding-runtime/contracts';
|
||||
import type {
|
||||
ConsultationRole,
|
||||
TeacherCheckInInput,
|
||||
TeacherCheckInResult,
|
||||
TeacherDefinition,
|
||||
TeacherReference,
|
||||
TeacherSend,
|
||||
TeacherSourceContext,
|
||||
TeacherTopic,
|
||||
} from '../../shared/coding-teacher';
|
||||
import { TEACHER_CHECK_IN_INTERVAL_MS, TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS } from '../../shared/coding-teacher';
|
||||
import {
|
||||
currentTeacherAccount,
|
||||
assertTeacherAccount,
|
||||
@@ -55,7 +58,7 @@ export class CodingTeacherService {
|
||||
private readonly tails = new Map<string, Promise<unknown>>();
|
||||
private readonly active = new Map<
|
||||
string,
|
||||
{ account: TeacherAccount; controller: AbortController; sourceId?: string; projectId: string }
|
||||
{ account: TeacherAccount; controller: AbortController; sourceId?: string; projectId: string; role: ConsultationRole }
|
||||
>();
|
||||
private readonly finishes = new Map<string, Promise<void>>();
|
||||
private readonly deletingSources = new Set<string>();
|
||||
@@ -119,6 +122,9 @@ export class CodingTeacherService {
|
||||
private key(account: TeacherAccount, scope: TeacherScope, id: string) {
|
||||
return account.id + ':' + scope.projectId + ':' + scope.sourceId + ':' + (scope.role ?? 'teacher') + ':' + id;
|
||||
}
|
||||
private acceptanceKey(account: TeacherAccount, scope: TeacherScope) {
|
||||
return account.id + ':' + scope.projectId + ':teacher-acceptance';
|
||||
}
|
||||
private async serialize<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.tails.get(key) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(operation);
|
||||
@@ -196,23 +202,102 @@ export class CodingTeacherService {
|
||||
throw new TeacherError(404, 'teacher_topic_not_found', '老师话题不存在。');
|
||||
return { store, topic };
|
||||
}
|
||||
async read(scope: TeacherScope, id: string) {
|
||||
async read(scope: TeacherScope, id: string, select = true) {
|
||||
const account = await this.account();
|
||||
const { store, topic } = await this.readOwned(account, scope, id);
|
||||
await store.select(id);
|
||||
if (select) await store.select(id);
|
||||
return structuredClone(topic);
|
||||
}
|
||||
async send(scope: TeacherScope, id: string, input: TeacherSend): Promise<TeacherTopic> {
|
||||
// Background turns must pass the project-wide cooldown and source checks.
|
||||
if (input.intent === 'check-in')
|
||||
throw new TeacherError(422, 'teacher_intent_invalid', '主动关心只能由项目老师检查发起。');
|
||||
const account = await this.account();
|
||||
if (scope.projectId !== 'preview' && (scope.role ?? 'teacher') === 'teacher')
|
||||
return await this.serialize(this.acceptanceKey(account, scope), () => this.sendRequest(account, scope, id, input));
|
||||
return await this.sendRequest(account, scope, id, input);
|
||||
}
|
||||
async checkIn(scope: TeacherScope, input: TeacherCheckInInput): Promise<TeacherCheckInResult> {
|
||||
if (scope.projectId === 'preview' || scope.sourceId !== 'project' || (scope.role ?? 'teacher') !== 'teacher')
|
||||
throw new TeacherError(422, 'teacher_intent_invalid', '主动关心只适用于项目里的老师。');
|
||||
teacherTopicId(input.requestId);
|
||||
teacherTopicId(input.sourceConversationId);
|
||||
const account = await this.account();
|
||||
return await this.serialize(this.acceptanceKey(account, scope), async () => {
|
||||
this.assertAccount(account);
|
||||
const store = await this.scopedStore(account, scope);
|
||||
const list = await store.list();
|
||||
const topics = await Promise.all(list.items.map(async (item) => (await this.readOwned(account, scope, item.id)).topic));
|
||||
for (const topic of topics) {
|
||||
const existing = topic.requests.find((request) => request.id === input.requestId);
|
||||
if (!existing) continue;
|
||||
if (existing.intent !== 'check-in' || existing.sourceConversationId !== input.sourceConversationId)
|
||||
throw new TeacherError(409, 'teacher_request_conflict', '同一请求标识不能用于不同问题。');
|
||||
this.assertAccount(account);
|
||||
return { topic: structuredClone(topic) };
|
||||
}
|
||||
if ([...this.active.values()].some((run) => run.account.id === account.id
|
||||
&& run.projectId === scope.projectId && run.role === 'teacher')
|
||||
|| topics.some((topic) => topic.requests.some((request) => ['preparing', 'running'].includes(request.status))))
|
||||
return { topic: null, skipped: 'busy' };
|
||||
const checks = topics.flatMap((topic) => topic.requests.filter((request) => request.intent === 'check-in'));
|
||||
if (checks.some((request) => Date.now() - Date.parse(request.createdAt) < TEACHER_CHECK_IN_INTERVAL_MS))
|
||||
return { topic: null, skipped: 'cooldown' };
|
||||
if (this.deletingSources.has(scope.projectId + ':' + input.sourceConversationId))
|
||||
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
|
||||
const project = await this.options.projects.getProject(scope.projectId);
|
||||
const conversation = await this.options.projects.conversationStore(project.path).get(input.sourceConversationId);
|
||||
if (!conversation)
|
||||
throw new TeacherError(404, 'teacher_source_not_found', '来源会话不属于当前项目。');
|
||||
if (conversation.archivedAt) return { topic: null, skipped: 'archived' };
|
||||
const available = await (this.options.availability ?? teacherAvailability)(account);
|
||||
this.assertAccount(account);
|
||||
if (!available.enabled || !available.published_version) return { topic: null, skipped: 'disabled' };
|
||||
const source = await (this.options.readSource?.({ ...scope, sourceId: input.sourceConversationId })
|
||||
?? readTeacherSource(this.options.projects, this.options.runtime, this.options.userDataDir, scope.projectId, input.sourceConversationId));
|
||||
this.assertAccount(account);
|
||||
const selectedTopic = topics.find((candidate) => candidate.id === list.lastSelectedTopicId);
|
||||
// Student discussion is progress too; proactive replies themselves must not
|
||||
// change this digest and cause another identical check-in five minutes later.
|
||||
const discussion = (selectedTopic?.requests ?? [])
|
||||
.filter((request) => request.intent !== 'check-in' && request.status === 'completed')
|
||||
.map(({ id, text, response }) => [id, text, response])
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
if (!source.messages.some((message) => message.text.trim()) && discussion.length === 0)
|
||||
return { topic: null, skipped: 'no-context' };
|
||||
const fingerprint = createHash('sha256')
|
||||
.update(JSON.stringify({ source: source.messages.map(({ id, role, text }) => [id, role, text]), discussion }))
|
||||
.digest('hex');
|
||||
if (checks.some((request) => request.sourceConversationId === input.sourceConversationId
|
||||
&& request.status === 'completed'
|
||||
&& request.checkInSourceFingerprint === fingerprint
|
||||
&& Date.now() - Date.parse(request.createdAt) < TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS))
|
||||
return { topic: null, skipped: 'unchanged' };
|
||||
const topic = selectedTopic ?? await this.create(scope);
|
||||
return {
|
||||
topic: await this.sendRequest(account, scope, topic.id, {
|
||||
...input, intent: 'check-in', text: '',
|
||||
}, { source, fingerprint }),
|
||||
};
|
||||
});
|
||||
}
|
||||
private async sendRequest(
|
||||
account: TeacherAccount,
|
||||
scope: TeacherScope,
|
||||
id: string,
|
||||
input: TeacherSend,
|
||||
checkIn?: { source: TeacherSourceContext; fingerprint: string }
|
||||
): Promise<TeacherTopic> {
|
||||
teacherTopicId(input.requestId);
|
||||
const intent = input.intent === undefined ? 'question' : input.intent;
|
||||
if (!['question', 'suggestions', 'guided-help'].includes(intent))
|
||||
if (!['question', 'suggestions', 'guided-help', 'check-in'].includes(intent) || (intent === 'check-in' && !checkIn))
|
||||
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)
|
||||
if (typeof input.text !== 'string' || (intent !== 'check-in' && !input.text.trim()) || input.text.length > 6000)
|
||||
throw new TeacherError(422, 'teacher_question_invalid', '请输入 1–6000 字的问题。');
|
||||
const refs = input.references ?? [];
|
||||
if (
|
||||
@@ -228,8 +313,7 @@ export class CodingTeacherService {
|
||||
refs.reduce((n, ref) => n + ref.text.length, 0) > 12000
|
||||
)
|
||||
throw new TeacherError(422, 'teacher_reference_invalid', '引用内容无效或超过 12000 字。');
|
||||
const account = await this.account(),
|
||||
key = this.key(account, scope, id);
|
||||
const key = this.key(account, scope, id);
|
||||
return await this.serialize(key, async () => {
|
||||
const { store, topic } = await this.readOwned(account, scope, id);
|
||||
const existing = topic.requests.find((request) => request.id === input.requestId);
|
||||
@@ -262,11 +346,14 @@ export class CodingTeacherService {
|
||||
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
|
||||
if (scope.sourceId === 'project' && sourceId) {
|
||||
const project = await this.options.projects.getProject(scope.projectId);
|
||||
if (!(await this.options.projects.conversationStore(project.path).get(sourceId)))
|
||||
const conversation = await this.options.projects.conversationStore(project.path).get(sourceId);
|
||||
if (!conversation)
|
||||
throw new TeacherError(404, 'teacher_source_not_found', '来源会话不属于当前项目。');
|
||||
if (checkIn && conversation.archivedAt)
|
||||
throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。');
|
||||
}
|
||||
const source: TeacherSourceContext =
|
||||
scope.projectId === 'preview'
|
||||
checkIn ? checkIn.source : scope.projectId === 'preview'
|
||||
? {
|
||||
messages: topic.sampleContext
|
||||
? [{ id: 'preview', role: 'user', text: topic.sampleContext }]
|
||||
@@ -304,6 +391,7 @@ export class CodingTeacherService {
|
||||
account,
|
||||
topic.definition
|
||||
);
|
||||
if (checkIn) this.assertAccount(account);
|
||||
const compiled = compileTeacherContext(
|
||||
topic.definition,
|
||||
source,
|
||||
@@ -317,10 +405,20 @@ export class CodingTeacherService {
|
||||
// is being deleted. Project consultations must recheck the actual source.
|
||||
if (sourceId && this.deletingSources.has(scope.projectId + ':' + sourceId))
|
||||
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
|
||||
if (checkIn && sourceId) {
|
||||
const project = await this.options.projects.getProject(scope.projectId);
|
||||
const conversation = await this.options.projects.conversationStore(project.path).get(sourceId);
|
||||
if (!conversation)
|
||||
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
|
||||
if (conversation.archivedAt)
|
||||
throw new TeacherError(409, 'teacher_source_archived', '来源会话已归档。');
|
||||
this.assertAccount(account);
|
||||
}
|
||||
const request = {
|
||||
id: input.requestId,
|
||||
intent,
|
||||
...(input.sourceConversationId ? { sourceConversationId: input.sourceConversationId } : {}),
|
||||
...(checkIn ? { checkInSourceFingerprint: checkIn.fingerprint } : {}),
|
||||
text: input.text,
|
||||
references,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -345,7 +443,7 @@ export class CodingTeacherService {
|
||||
// than leaving a permanently preparing request in the project topic.
|
||||
if (sourceId && this.deletingSources.has(scope.projectId + ':' + sourceId))
|
||||
controller.abort();
|
||||
this.active.set(key, { account, controller, sourceId, projectId: scope.projectId });
|
||||
this.active.set(key, { account, controller, sourceId, projectId: scope.projectId, role: scope.role ?? 'teacher' });
|
||||
const release = this.options.acquireLease?.(key) ?? (() => undefined);
|
||||
const finish = async () => {
|
||||
const current = topic.requests.at(-1)!;
|
||||
|
||||
@@ -33,7 +33,8 @@ export class TeacherTopicStore {
|
||||
const items = topics
|
||||
.map((topic) => ({
|
||||
id: topic.id,
|
||||
title: topic.requests[0]?.text.slice(0, 32) || '新话题',
|
||||
title: topic.requests.find((request) => request.intent !== 'check-in')?.text.slice(0, 32)
|
||||
|| (topic.requests.some((request) => request.intent === 'check-in') ? '和老师聊聊' : '新话题'),
|
||||
updatedAt: topic.updatedAt,
|
||||
version: topic.version,
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user