Merge latest main and preserve teacher upgrade safeguards
This commit is contained in:
@@ -63,12 +63,12 @@ export function teacherHistoryMessages(history: TeacherRequest[]): TeacherSource
|
||||
text: [...(request.projectId ? ['当时的项目:' + (request.projectName ?? request.projectId)] : []),
|
||||
...request.references.map(ref => '明确引用:\n' + ref.text), request.text].join('\n\n'),
|
||||
}]),
|
||||
{
|
||||
...(request.replyIncomplete ? [] : [{
|
||||
id: 'teacher:' + request.id + ':assistant', role: 'assistant' as const,
|
||||
text: [request.response, ...(request.suggestedQuestions?.length
|
||||
? ['可以接着聊的问题:\n' + request.suggestedQuestions.map(text => '- ' + text).join('\n')]
|
||||
: [])].join('\n\n'),
|
||||
},
|
||||
}]),
|
||||
]);
|
||||
}
|
||||
// A bounded text estimate for the native model path, not an exact tokenizer or
|
||||
@@ -113,7 +113,7 @@ export function compileTeacherContext(
|
||||
const current: TeacherModelMessage = {
|
||||
role: intent === 'check-in' ? 'system' : 'user',
|
||||
content: intent === 'check-in'
|
||||
? '本轮是一次项目进展提醒,不是用户提问。按照已配置的人设和职责,结合来源操作对话中已完成的文字和咨询历史回应,表达方式遵循已配置的要求,不要求用户立即回答。仅依据已有证据,不重复上次提醒,不声称实际运行或试玩过作品。'
|
||||
? '本轮入口:程序触发的项目进展检查,学生没有在本轮主动提问。回应方式遵循当前智能体的云端配置。'
|
||||
: [
|
||||
...references.map(
|
||||
(ref) =>
|
||||
@@ -123,10 +123,10 @@ export function compileTeacherContext(
|
||||
ref.text
|
||||
),
|
||||
'当前问题:\n' + question,
|
||||
...(intent === 'suggestions' || intent === 'guided-help'
|
||||
? [
|
||||
'本轮学生希望你帮忙找到交流起点。依据当前项目、来源操作对话和咨询历史,推荐一个具体切入点,并说明为何值得从这里聊;快捷回复围绕这个切入点帮助学生接话。没有可用上下文时,坦诚从构思切入,不假定学生已完成功能,不编造作品表现或项目进展。',
|
||||
]
|
||||
...(intent === 'suggestions'
|
||||
? ['本轮入口:学生通过“帮我看看”主动请求帮助,尚未提出具体问题。']
|
||||
: intent === 'guided-help'
|
||||
? ['本轮入口:学生主动求助,表示暂时说不清想问什么。']
|
||||
: []),
|
||||
].join('\n\n'),
|
||||
};
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { TeacherDefinition, TeacherHistoryPage, TeacherRequest, TeacherTopi
|
||||
import { excerptTeacherText, teacherHistoryMessages } from './context';
|
||||
import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json';
|
||||
import { TeacherError } from './config-client';
|
||||
import { teacherTopicId } from './store';
|
||||
import { repairCompletedReply, teacherTopicId } from './store';
|
||||
|
||||
interface TurnIndex { id: string; createdAt: string; origin?: string; messages: ReturnType<typeof teacherHistoryMessages> }
|
||||
interface TurnIndex { replyProjectionVersion?: 1; id: string; createdAt: string; origin?: string; messages: ReturnType<typeof teacherHistoryMessages> }
|
||||
interface Manifest {
|
||||
topic: Omit<TeacherTopic, 'requests'>;
|
||||
turns: TurnIndex[];
|
||||
@@ -19,8 +19,13 @@ const isMissing = (error: unknown) => (error as NodeJS.ErrnoException)?.code ===
|
||||
const originKey = (request: TeacherRequest) => request.origin
|
||||
? [request.origin.projectId, request.origin.topicId, request.origin.requestId].join(':') : undefined;
|
||||
const order = (a: TurnIndex, b: TurnIndex) => a.createdAt.localeCompare(b.createdAt) || (a.origin ?? a.id).localeCompare(b.origin ?? b.id);
|
||||
const indexTurn = (turn: TeacherRequest): TurnIndex => ({ id: turn.id, createdAt: turn.createdAt, origin: originKey(turn),
|
||||
messages: teacherHistoryMessages([turn]).map(message => ({ ...message, text: excerptTeacherText(message.text, 180).replaceAll('\n', ' ') })) });
|
||||
const indexTurn = (turn: TeacherRequest): TurnIndex => {
|
||||
const projection = structuredClone(turn);
|
||||
repairCompletedReply(projection);
|
||||
return { id: turn.id, createdAt: turn.createdAt, origin: originKey(turn), replyProjectionVersion: 1,
|
||||
messages: teacherHistoryMessages([projection]).map(message => ({ ...message,
|
||||
text: excerptTeacherText(message.text, 180).replaceAll('\n', ' ') })) };
|
||||
};
|
||||
|
||||
/** A small index and one atomic file per turn. Only the latest page is held live. */
|
||||
export class TeacherConversationStore {
|
||||
@@ -65,17 +70,23 @@ export class TeacherConversationStore {
|
||||
turn.status = 'interrupted';
|
||||
turn.error = '应用已重启,本次回复中断。';
|
||||
await atomicWriteJson(this.turnPath(turn.id), turn);
|
||||
const index = manifest.turns.findIndex(item => item.id === turn.id);
|
||||
if (index >= 0) manifest.turns[index] = indexTurn(turn);
|
||||
recovered = true;
|
||||
}
|
||||
if (recovered) {
|
||||
this.live.revision++;
|
||||
await this.save(this.live);
|
||||
// Only restart metadata changes here; completed display repairs stay in memory.
|
||||
await this.save(this.live, '');
|
||||
}
|
||||
}
|
||||
private async turn(id: string) {
|
||||
const turn = await readJsonFile(this.turnPath(id)) as TeacherRequest;
|
||||
if (turn.id !== id || !Array.isArray(turn.references))
|
||||
throw new TeacherError(409, 'teacher_history_invalid', '智能体消息无法读取,请保留本机记录。');
|
||||
repairCompletedReply(turn);
|
||||
const index = this.manifest?.turns.findIndex(item => item.id === turn.id) ?? -1;
|
||||
if (this.manifest && index >= 0) this.manifest.turns[index] = indexTurn(turn);
|
||||
return turn;
|
||||
}
|
||||
async exists() { await this.load(); return Boolean(this.manifest); }
|
||||
@@ -114,7 +125,11 @@ export class TeacherConversationStore {
|
||||
const entries = structuredClone(this.manifest?.turns ?? []);
|
||||
const owners = new Map(entries.flatMap(turn => turn.messages.map(message => [message.id, turn.id] as const)));
|
||||
return {
|
||||
messages: entries.flatMap(turn => turn.messages),
|
||||
// Older indexes may contain the unsafe quoted prefix. Keep them lazy:
|
||||
// expose an ID for reading, not an unverified assistant excerpt.
|
||||
messages: entries.flatMap(turn => turn.messages.map(message =>
|
||||
turn.replyProjectionVersion === 1 || message.role !== 'assistant' ? message
|
||||
: { ...message, text: '(按消息 ID 读取完整回复)' })),
|
||||
read: async (id: string) => {
|
||||
const owner = owners.get(id);
|
||||
return owner ? teacherHistoryMessages([await this.turn(owner)]).find(message => message.id === id) : undefined;
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import type { TeacherRequest } from '../../shared/coding-teacher';
|
||||
import { parseTeacherReply } from '../../shared/teacher-reply';
|
||||
|
||||
const MAX_CURRENT_QUICK_REPLIES = 3;
|
||||
|
||||
export function teacherReplyInstructions(): string {
|
||||
return '本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的后续回复建议,没有建议时用空数组。只使用这两个字段。依据已配置职责帮助学生整理想法、理解关系、承接已确认的共识;区分学生已确认内容与智能体建议、待定想法。回复的语言、风格、长度、前缀和建议内容遵循当前智能体的云端配置。';
|
||||
return `本轮界面接收一个 JSON 对象 {"reply":string,"quickReplies":string[]}。reply 是回复正文,可使用 Markdown;quickReplies 是可选的快捷回复,最多 ${MAX_CURRENT_QUICK_REPLIES} 条,每条为非空字符串,没有快捷回复时用空数组。只使用这两个字段。字符串中的英文双引号、反斜杠和换行必须按 JSON 规则转义,确保整个对象是合法 JSON。回复的内容、语言、风格、长度和前缀遵循当前智能体的云端配置。`;
|
||||
}
|
||||
|
||||
/** The original response remains available for diagnosis when parsing is incomplete. */
|
||||
export function applyTeacherReply(request: TeacherRequest, raw: string): void {
|
||||
const parsed = parseTeacherReply(raw);
|
||||
// Apply the current UI contract only to new replies; archived replies keep
|
||||
// their original cards through the compatibility parser and topic store.
|
||||
if (parsed.quickReplies.length > MAX_CURRENT_QUICK_REPLIES) {
|
||||
request.replyQuickRepliesRejected = true;
|
||||
parsed.quickReplies = [];
|
||||
parsed.parseError ??= '快捷回复超过当前界面最多 3 条的限制,正文已保留,快捷回复未展示。';
|
||||
} else delete request.replyQuickRepliesRejected;
|
||||
request.response = parsed.reply;
|
||||
request.suggestedQuestions = parsed.quickReplies;
|
||||
if (parsed.incomplete) request.replyIncomplete = true;
|
||||
else delete request.replyIncomplete;
|
||||
if (parsed.parseError) {
|
||||
request.replyParseError = parsed.parseError;
|
||||
request.unparsedResponse = raw;
|
||||
} else {
|
||||
delete request.replyParseError;
|
||||
delete request.unparsedResponse;
|
||||
delete request.discussionError;
|
||||
}
|
||||
if (parsed.parseError || parsed.recovered) request.unparsedResponse = raw;
|
||||
else delete request.unparsedResponse;
|
||||
}
|
||||
|
||||
@@ -479,11 +479,13 @@ export class CodingTeacherService {
|
||||
const previous = topic.requests.at(-1);
|
||||
const reuse = previous && !previous.origin && previous.projectId === scope.projectId
|
||||
&& previous.sourceConversationId === input.sourceConversationId
|
||||
&& previous.presentation === 'reply-v1'
|
||||
&& previous.teacherVersion === selected.version && previous.runtimeThreadId;
|
||||
newConversationSegment = !reuse;
|
||||
// Yuxi binds both project and Pi source to a thread. Preserve its native
|
||||
// summary middleware within a segment; explicitly carry bounded public
|
||||
// history only when a new scope/release starts another segment.
|
||||
// history only when a new scope, release, or reply protocol starts another
|
||||
// segment. Older checkpoints may still contain retired component instructions.
|
||||
topic.conversation!.runtimeThreadId = reuse || randomUUID();
|
||||
topic.conversation!.segmentTurns = reuse ? topic.conversation!.segmentTurns + 1 : 1;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
import { mkdir, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json';
|
||||
import type { TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher';
|
||||
import type { TeacherRequest, TeacherTopic, TeacherTopicList } from '../../shared/coding-teacher';
|
||||
import { isTeacherReplyEnvelope, parseTeacherReply } from '../../shared/teacher-reply';
|
||||
import { TeacherError } from './config-client';
|
||||
|
||||
/** Repair only received, completed protocol replies; never resume a model request. */
|
||||
export function repairCompletedReply(request: TeacherRequest): void {
|
||||
const raw = request.unparsedResponse;
|
||||
if (request.status !== 'completed' || typeof raw !== 'string'
|
||||
|| !(request.discussionError || request.replyParseError || request.replyIncomplete)
|
||||
|| !isTeacherReplyEnvelope(raw)) return;
|
||||
const parsed = parseTeacherReply(raw);
|
||||
if (request.replyQuickRepliesRejected) {
|
||||
parsed.quickReplies = [];
|
||||
parsed.parseError ??= request.replyParseError;
|
||||
}
|
||||
if (parsed.incomplete) {
|
||||
// Keep the previous text and diagnostics as evidence, but do not present a
|
||||
// legacy half-sentence as a complete reply.
|
||||
request.replyIncomplete = true;
|
||||
return;
|
||||
}
|
||||
const previous = request.response.trim();
|
||||
// The retained raw data must extend or confirm the existing prose. A stale or
|
||||
// unrelated archive must not replace a useful answer with different content.
|
||||
if (previous && !parsed.reply.trim().startsWith(previous)
|
||||
&& previous !== '这次回复没有完整解析,请重试。'
|
||||
&& previous !== raw.trim() && !request.replyIncomplete) return;
|
||||
request.response = parsed.reply;
|
||||
request.suggestedQuestions = parsed.quickReplies;
|
||||
delete request.replyIncomplete;
|
||||
if (parsed.parseError) request.replyParseError = parsed.parseError;
|
||||
else {
|
||||
delete request.replyParseError;
|
||||
delete request.discussionError;
|
||||
}
|
||||
// Unlike applying a new reply, reading history never discards the original.
|
||||
}
|
||||
|
||||
export function teacherTopicId(id: string): string {
|
||||
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id))
|
||||
throw new TeacherError(400, 'teacher_topic_invalid', '智能体话题标识无效。');
|
||||
@@ -60,16 +95,20 @@ export class TeacherTopicStore {
|
||||
throw new TeacherError(409, 'teacher_topic_invalid', '智能体历史无法读取。');
|
||||
let recovered = false;
|
||||
// Legacy component fields are opaque archives: never validate, revive or delete them.
|
||||
for (const request of topic.requests)
|
||||
for (const request of topic.requests) {
|
||||
if (request.status === 'preparing' || request.status === 'running') {
|
||||
request.status = 'interrupted';
|
||||
request.error = '应用已重启,本次回复中断。';
|
||||
recovered = true;
|
||||
}
|
||||
}
|
||||
if (recovered) {
|
||||
topic.revision++;
|
||||
await this.save(topic);
|
||||
}
|
||||
// Apply display repair after restart recovery is saved, so reading a
|
||||
// mixed topic does not incidentally persist completed-reply repairs.
|
||||
for (const request of topic.requests) repairCompletedReply(request);
|
||||
return topic;
|
||||
})();
|
||||
this.cache.set(id, pending);
|
||||
|
||||
Reference in New Issue
Block a user